diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 37fc048a0e..9304447079 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -8,6 +8,8 @@ body: value: | Thanks for helping improve the Supabase CLI. Please include the exact command and output whenever possible so maintainers can reproduce the issue quickly. + Open this issue **before** starting work — a maintainer will add the `open-for-contribution` label once it is triaged and ready. Pull requests from external contributors that don't link an open, `open-for-contribution` issue are closed automatically. See [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md). + - type: dropdown id: affected-area attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8c49825527..00644d304d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: 📖 Contribution workflow + url: https://github.com/supabase/cli/blob/develop/CONTRIBUTING.md + about: Read this before opening an issue or pull request — PRs must link a labeled, open issue. - name: Supabase support url: https://supabase.com/support about: Get help with your Supabase project, account, billing, or hosted services. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml index 57501fd9d3..aa04c9c2d3 100644 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -3,6 +3,11 @@ description: Suggest an improvement to Supabase CLI documentation. labels: - 📘 Docs body: + - type: markdown + attributes: + value: | + Thanks for helping improve the docs! Open this issue **before** starting work — a maintainer will add the `open-for-contribution` label once it is triaged and ready. Pull requests from external contributors that don't link an open, `open-for-contribution` issue are closed automatically. See [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md). + - type: input id: link attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 12bb26e54e..fd3a0eda1d 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -3,6 +3,11 @@ description: Suggest an improvement or new capability for the Supabase CLI. labels: - ✨ Feature body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! Open this issue **before** starting work — a maintainer will add the `open-for-contribution` label once it is triaged and ready. Pull requests from external contributors that don't link an open, `open-for-contribution` issue are closed automatically. See [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md). + - type: checkboxes id: existing-issues attributes: diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md new file mode 100644 index 0000000000..e8321dd3b9 --- /dev/null +++ b/.github/MAINTAINERS.md @@ -0,0 +1,58 @@ +# Maintainers guide + +Internal notes for maintaining the Supabase CLI contribution workflow. See +[`CONTRIBUTING.md`](../CONTRIBUTING.md) for the contributor-facing version. + +## The `open-for-contribution` gate + +External pull requests are only accepted when they link to an **open** GitHub issue +that carries the **`open-for-contribution`** label. This is enforced by the +[`Contribution Gate`](./workflows/contribution-gate.yml) workflow, whose decision logic +lives in [`scripts/contribution-gate.ts`](./scripts/contribution-gate.ts). + +The gate runs **reactively on each PR** so a non-conforming PR is closed right away, and +can also be **swept across every open PR on demand** via the workflow's *Run workflow* +button. + +A pull request is **auto-closed with an explanatory comment** when the author is external +and any of these is true: + +- no issue is linked (via a closing keyword such as `Closes #123`, or the PR's + Development sidebar), or +- the linked issue is closed, or +- the linked issue is missing the `open-for-contribution` label. + +Authors whose `author_association` is `OWNER`, `MEMBER`, or `COLLABORATOR`, and bot +accounts, are **exempt** — Supabase maintainers can keep working from Linear tickets that +aren't public on GitHub. + +### Running the gate manually + +Use **Actions → Contribution Gate → Run workflow** to sweep all open PRs on demand — for +example right after bulk-applying `open-for-contribution` labels, so the whole backlog is +re-evaluated at once instead of waiting for each PR's next edit. Set the **`dry_run`** +input to `true` first to log each PR's decision in the run output without commenting on or +closing anything; run again with `dry_run` unchecked to apply the decisions. + +## Triage: applying the label (manual) + +During triage: + +1. Categorize the issue with one of `✨ Feature`, `🐛 Bug`, or `📘 Docs`. Issues opened + via the templates start with their category label already applied. +2. When the issue is ready to be worked on, add the **`open-for-contribution`** label. + +The `open-for-contribution` label must exist as a repository label for this workflow to +function; create it once from **Issues → Labels** if it is missing. + +Applying `open-for-contribution` is currently a **manual step** — do it on the GitHub +issue directly (from the GitHub UI, or from the Linear-linked issue). + +## Deferred: automatic Linear → GitHub label sync + +We considered auto-applying `open-for-contribution` when a Linear issue moves out of +Triage/Backlog (e.g. to Todo). Linear's native GitHub automations are one-directional +(GitHub events update Linear status) and cannot push a GitHub label, so this would need an +external bridge (a scheduled job polling the Linear API, a Zapier/Make zap, or a Linear +webhook → relay). It is **out of scope for now** and tracked separately; until then, apply +the label manually as above. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..2c8e119819 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +## Summary + + + +## Linked issue + +Closes # + +- [ ] The linked issue is **open** and carries the `open-for-contribution` label (or I'm a Supabase maintainer). + +## Checklist + +- [ ] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`). +- [ ] Tests added or updated for the change. +- [ ] `pnpm check:all` and `pnpm test` pass for the workspace(s) I touched. diff --git a/.github/scripts/contribution-gate.test.ts b/.github/scripts/contribution-gate.test.ts new file mode 100644 index 0000000000..7951de817a --- /dev/null +++ b/.github/scripts/contribution-gate.test.ts @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { + evaluateAllOpenPrs, + evaluateGate, + fetchAuthorPermission, + GATE_LABEL, + type GateIo, + isInternalAuthor, + type LinkedIssue, + type OpenPr, +} from "./contribution-gate.ts"; + +const REPO = "supabase/cli"; + +describe("isInternalAuthor", () => { + test.each(["OWNER", "MEMBER", "COLLABORATOR"])( + "treats internal association %s as internal without a permission lookup", + (authorAssociation) => { + expect(isInternalAuthor(authorAssociation, undefined)).toBe(true); + }, + ); + + test.each(["admin", "write"])("treats push-capable permission %s as internal", (permission) => { + // A private org member surfaces only as CONTRIBUTOR/NONE via + // author_association, but their effective repo permission gives them + // away as a maintainer. + expect(isInternalAuthor("CONTRIBUTOR", permission)).toBe(true); + expect(isInternalAuthor("NONE", permission)).toBe(true); + }); + + test.each(["read", "none", undefined])( + "treats external author with permission %s as external", + (permission) => { + expect(isInternalAuthor("CONTRIBUTOR", permission)).toBe(false); + expect(isInternalAuthor("NONE", permission)).toBe(false); + }, + ); +}); + +describe("evaluateGate", () => { + test("skips bot authors", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: true, + linkedIssues: [], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("bot"); + }); + + test("skips internal authors", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: true, + isBot: false, + linkedIssues: [], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("internal"); + }); + + test("fails when no issue is linked", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("no-linked-issue"); + expect(result.message).toContain(GATE_LABEL); + expect(result.message).toContain("CONTRIBUTING.md"); + }); + + test("fails when the linked issue is open but not labeled", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [{ repository: REPO, number: 12, state: "OPEN", labels: ["🐛 Bug"] }], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("missing-label"); + expect(result.message).toContain(GATE_LABEL); + }); + + test("fails when the only labeled issue is closed", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [{ repository: REPO, number: 7, state: "CLOSED", labels: [GATE_LABEL] }], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("issue-closed"); + }); + + test("passes when a linked issue is open and carries the gate label", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [ + { + repository: REPO, + number: 42, + state: "OPEN", + labels: [GATE_LABEL, "🐛 Bug"], + }, + ], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("ok"); + }); + + test("passes when any one of several linked issues qualifies", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [ + { repository: REPO, number: 1, state: "CLOSED", labels: [GATE_LABEL] }, + { repository: REPO, number: 2, state: "OPEN", labels: ["✨ Feature"] }, + { repository: REPO, number: 3, state: "OPEN", labels: [GATE_LABEL] }, + ], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("ok"); + }); + + test("ignores an open, labeled issue from a different repository", () => { + // Cross-repo closing keyword (e.g. `Closes attacker/repo#1`): the issue is + // controlled by the contributor, so it must not satisfy the gate. + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [ + { + repository: "attacker/repo", + number: 1, + state: "OPEN", + labels: [GATE_LABEL], + }, + ], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("no-linked-issue"); + }); + + test("matches the repository case-insensitively", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [ + { + repository: "Supabase/CLI", + number: 5, + state: "OPEN", + labels: [GATE_LABEL], + }, + ], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("ok"); + }); +}); + +describe("evaluateAllOpenPrs", () => { + function makeIo( + openPrs: OpenPr[], + linkedByPr: Record, + permissionByLogin: Record = {}, + ): { + io: GateIo; + closed: Array<{ number: number; message: string }>; + permissionLookups: string[]; + } { + const closed: Array<{ number: number; message: string }> = []; + const permissionLookups: string[] = []; + const io: GateIo = { + listOpenPrs: () => Promise.resolve(openPrs), + fetchPermission: (login) => { + permissionLookups.push(login); + return Promise.resolve(permissionByLogin[login]); + }, + fetchLinkedIssues: (prNumber) => Promise.resolve(linkedByPr[prNumber] ?? []), + closePr: (prNumber, message) => { + closed.push({ number: prNumber, message }); + return Promise.resolve(); + }, + }; + return { io, closed, permissionLookups }; + } + + test("closes only non-conforming external PRs and leaves the rest", async () => { + const { io, closed, permissionLookups } = makeIo( + [ + // no issue -> close + { number: 1, authorLogin: "ext-a", authorAssociation: "NONE", isBot: false }, + // public member -> skip (no permission lookup needed) + { number: 2, authorLogin: "maint", authorAssociation: "MEMBER", isBot: false }, + // bot -> skip + { number: 3, authorLogin: "dependabot", authorAssociation: "NONE", isBot: true }, + // conforming external -> keep + { number: 4, authorLogin: "ext-b", authorAssociation: "CONTRIBUTOR", isBot: false }, + // missing label -> close + { number: 5, authorLogin: "ext-c", authorAssociation: "NONE", isBot: false }, + // private-membership maintainer (CONTRIBUTOR + write access) -> skip + { number: 6, authorLogin: "hidden-maint", authorAssociation: "CONTRIBUTOR", isBot: false }, + ], + { + 4: [{ repository: REPO, number: 40, state: "OPEN", labels: [GATE_LABEL] }], + 5: [{ repository: REPO, number: 50, state: "OPEN", labels: ["🐛 Bug"] }], + }, + { "hidden-maint": "write" }, + ); + + const entries = await evaluateAllOpenPrs(io, REPO); + + expect(closed.map((c) => c.number).sort((a, b) => a - b)).toEqual([1, 5]); + const byNumber = Object.fromEntries(entries.map((entry) => [entry.number, entry.result])); + expect(byNumber[1]?.reason).toBe("no-linked-issue"); + expect(byNumber[2]?.pass).toBe(true); + expect(byNumber[2]?.reason).toBe("internal"); + expect(byNumber[3]?.reason).toBe("bot"); + expect(byNumber[4]?.pass).toBe(true); + expect(byNumber[5]?.reason).toBe("missing-label"); + expect(byNumber[6]?.pass).toBe(true); + expect(byNumber[6]?.reason).toBe("internal"); + expect(closed.find((c) => c.number === 1)?.message).toContain(GATE_LABEL); + // Bots and public members are settled without a permission lookup. + expect(permissionLookups).not.toContain("maint"); + expect(permissionLookups).not.toContain("dependabot"); + }); + + test("returns an entry per PR and closes none when all conform", async () => { + const { io, closed } = makeIo( + [{ number: 9, authorLogin: "ext", authorAssociation: "NONE", isBot: false }], + { + 9: [{ repository: REPO, number: 90, state: "OPEN", labels: [GATE_LABEL] }], + }, + ); + + const entries = await evaluateAllOpenPrs(io, REPO); + + expect(entries).toHaveLength(1); + expect(closed).toHaveLength(0); + expect(entries[0]?.result.pass).toBe(true); + }); +}); + +describe("fetchAuthorPermission", () => { + const fetchSpy = spyOn(globalThis, "fetch"); + afterEach(() => { + fetchSpy.mockReset(); + }); + + function stubFetch(status: number, body: unknown): void { + // `typeof fetch` carries a static `preconnect`, so attach a no-op to keep + // the implementation assignable without casting. + fetchSpy.mockImplementation( + Object.assign(() => Promise.resolve(new Response(JSON.stringify(body), { status })), { + preconnect: () => {}, + }), + ); + } + + test("returns the effective permission for a collaborator", async () => { + stubFetch(200, { permission: "admin" }); + const permission = await fetchAuthorPermission("t", "supabase", "cli", "maint"); + expect(permission).toBe("admin"); + }); + + test("maps a 404 (non-collaborator fork author) to undefined", async () => { + // The endpoint 404s for users who are not collaborators — the common case + // for the external contributors the gate targets. It must map to external, + // not abort the run. + stubFetch(404, { message: "Not Found" }); + const permission = await fetchAuthorPermission("t", "supabase", "cli", "ext"); + expect(permission).toBeUndefined(); + }); + + test("throws on other API failures so the run aborts without closing PRs", async () => { + stubFetch(403, { message: "Forbidden" }); + await expect(fetchAuthorPermission("t", "supabase", "cli", "ext")).rejects.toThrow(/403/); + }); + + test("skips the network call for a blank login", async () => { + stubFetch(200, { permission: "admin" }); + const permission = await fetchAuthorPermission("t", "supabase", "cli", ""); + expect(permission).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/.github/scripts/contribution-gate.ts b/.github/scripts/contribution-gate.ts new file mode 100644 index 0000000000..a2b62ee08f --- /dev/null +++ b/.github/scripts/contribution-gate.ts @@ -0,0 +1,505 @@ +/** + * Contribution gate: enforces the Supabase CLI contribution workflow across all + * OPEN pull requests opened by external contributors. + * + * A PR passes only when it links to an OPEN GitHub issue that carries the + * `open-for-contribution` label. Maintainers (recognised by their effective + * repository permission, so private org members count too — not just the + * `author_association` GitHub exposes for public members) and bots are exempt + * (they work from Linear tickets or automation). PRs that do not follow the + * process are commented on and closed. + * + * Two modes, both driven from `main()`: + * - single-PR (default): reacts to one PR on `pull_request_target` + * (`opened`/`reopened`/`edited`), using the PR_* env vars from the event. + * - all-PRs sweep (`GATE_MODE=all`, or the `--all` flag): evaluates every + * open PR, for on-demand `workflow_dispatch` runs. Set `DRY_RUN=true` to + * log decisions without commenting/closing. + * + * In both modes the workflow checks out the base branch, so this only ever + * executes trusted repository code — it never runs a fork's code. + * + * Run in CI as: `bun .github/scripts/contribution-gate.ts`. + * The pure `evaluateGate` decision and the `evaluateAllOpenPrs` orchestrator + * (I/O injected) are unit-tested in `contribution-gate.test.ts`; `main()` wires + * up the real GitHub I/O. + */ + +export const GATE_LABEL = "open-for-contribution"; + +/** + * Author associations treated as internal (exempt from the gate). + * + * NOTE: `author_association` only reports `MEMBER` when a user's organization + * membership is *public*. A private org member (or a team member who keeps + * their membership private) is reported as `CONTRIBUTOR`/`NONE`, so this set + * alone is not enough to identify internal maintainers — see + * `isInternalAuthor`, which also consults the author's effective repository + * permission. + */ +export const INTERNAL_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +/** + * Effective repository permissions that mark an author as internal. A user who + * can push to the repository (directly, or via a team/org grant that + * `author_association` does not surface) is a trusted maintainer, not an + * external contributor. The legacy REST `permission` field collapses the + * `maintain` role to `write`, so `admin`/`write` covers every push-capable + * role. + */ +const WRITE_PERMISSIONS = new Set(["admin", "write"]); + +/** + * Decide whether a PR author is internal (exempt from the gate). Combines the + * cheap `author_association` signal with the author's effective repository + * `permission` (undefined when it could not be resolved), so private org + * members whose association is merely `CONTRIBUTOR` are still recognised. + */ +export function isInternalAuthor( + authorAssociation: string, + permission: string | undefined, +): boolean { + return ( + INTERNAL_ASSOCIATIONS.has(authorAssociation) || + (permission !== undefined && WRITE_PERMISSIONS.has(permission)) + ); +} + +export interface LinkedIssue { + /** `owner/name` of the repo the issue lives in (from GraphQL nameWithOwner). */ + repository: string; + number: number; + state: "OPEN" | "CLOSED"; + labels: string[]; +} + +export interface GateInput { + /** `owner/name` of this repository (from GITHUB_REPOSITORY). */ + repository: string; + /** Whether the author is a trusted maintainer (see `isInternalAuthor`). */ + isInternal: boolean; + isBot: boolean; + linkedIssues: LinkedIssue[]; +} + +export type GateReason = + | "bot" + | "internal" + | "ok" + | "no-linked-issue" + | "missing-label" + | "issue-closed"; + +export interface GateResult { + pass: boolean; + reason: GateReason; + /** Explanatory comment body, present only when `pass` is false. */ + message?: string; +} + +const DOCS_FOOTER = + `\nSee [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md) for the full workflow. ` + + `Once a maintainer adds the \`${GATE_LABEL}\` label to a linked open issue, ` + + `reopen or open a new pull request and it will be accepted.`; + +function buildMessage(reason: GateReason): string { + switch (reason) { + case "no-linked-issue": + return ( + `👋 Thanks for the contribution! This pull request isn't linked to a ` + + `tracked issue, so it's being closed automatically.\n\n` + + `Please open an issue first, wait for a maintainer to add the ` + + `\`${GATE_LABEL}\` label, then open a pull request that links the issue ` + + `with a closing keyword (e.g. \`Closes #123\`).${DOCS_FOOTER}` + ); + case "missing-label": + return ( + `👋 Thanks! The linked issue hasn't been marked \`${GATE_LABEL}\` yet, ` + + `so this pull request is being closed automatically.\n\n` + + `A maintainer adds that label once an issue is triaged and ready to be ` + + `worked on. Please wait for the label before opening a pull ` + + `request.${DOCS_FOOTER}` + ); + case "issue-closed": + return ( + `👋 The issue linked to this pull request is closed, so it's being ` + + `closed automatically.\n\n` + + `Please link an open issue that carries the \`${GATE_LABEL}\` ` + + `label.${DOCS_FOOTER}` + ); + default: + return ""; + } +} + +/** + * Pure decision function for the contribution gate. Given the PR author context + * and its linked issues, returns whether the PR is allowed and why. + */ +export function evaluateGate(input: GateInput): GateResult { + if (input.isBot) { + return { pass: true, reason: "bot" }; + } + if (input.isInternal) { + return { pass: true, reason: "internal" }; + } + + // Only issues in THIS repository count. A cross-repository closing keyword + // (e.g. `Closes attacker/repo#1`) links an issue the contributor controls, + // so it must never satisfy the gate. + const repo = input.repository.toLowerCase(); + const repoIssues = input.linkedIssues.filter((issue) => issue.repository.toLowerCase() === repo); + + if (repoIssues.length === 0) { + return { + pass: false, + reason: "no-linked-issue", + message: buildMessage("no-linked-issue"), + }; + } + + const qualifies = repoIssues.some( + (issue) => issue.state === "OPEN" && issue.labels.includes(GATE_LABEL), + ); + if (qualifies) { + return { pass: true, reason: "ok" }; + } + + const hasOpenIssue = repoIssues.some((issue) => issue.state === "OPEN"); + const reason: GateReason = hasOpenIssue ? "missing-label" : "issue-closed"; + return { pass: false, reason, message: buildMessage(reason) }; +} + +/** Minimal open-PR shape the gate needs to decide exemption. */ +export interface OpenPr { + number: number; + authorLogin: string; + authorAssociation: string; + isBot: boolean; +} + +/** Injected GitHub I/O so the sweep can be unit-tested without the network. */ +export interface GateIo { + /** List every open pull request in the repository. */ + listOpenPrs: () => Promise; + /** + * Resolve an author's effective repository permission (`admin`/`write`/ + * `read`/`none`), or `undefined` when it could not be determined. Used to + * recognise maintainers whose org membership is private. + */ + fetchPermission: (login: string) => Promise; + /** Fetch the issues a PR closes (via `closingIssuesReferences`). */ + fetchLinkedIssues: (prNumber: number) => Promise; + /** Comment with `message` then close the PR. Called only for failing PRs. */ + closePr: (prNumber: number, message: string) => Promise; +} + +export interface SweepEntry { + number: number; + result: GateResult; +} + +/** + * Evaluate the gate against every open PR, closing the ones that fail. Pure + * orchestration over the injected `io`; returns each PR's decision so the + * caller can log a summary. + */ +export async function evaluateAllOpenPrs(io: GateIo, repository: string): Promise { + const openPrs = await io.listOpenPrs(); + const entries: SweepEntry[] = []; + for (const pr of openPrs) { + // Only pay for a permission lookup when the cheap signals are inconclusive: + // bots are exempt outright, and a public internal association already + // proves membership. + let isInternal = pr.isBot || INTERNAL_ASSOCIATIONS.has(pr.authorAssociation); + if (!isInternal) { + const permission = await io.fetchPermission(pr.authorLogin); + isInternal = isInternalAuthor(pr.authorAssociation, permission); + } + const linkedIssues = await io.fetchLinkedIssues(pr.number); + const result = evaluateGate({ + repository, + isInternal, + isBot: pr.isBot, + linkedIssues, + }); + if (!result.pass && result.message) { + await io.closePr(pr.number, result.message); + } + entries.push({ number: pr.number, result }); + } + return entries; +} + +// --- GitHub I/O (only runs when executed directly) --- + +interface GraphQLIssueNode { + number: number; + state: "OPEN" | "CLOSED"; + repository: { nameWithOwner: string }; + labels: { nodes: Array<{ name: string }> }; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +async function githubFetch( + url: string, + token: string, + init: Omit = {}, + /** Non-OK statuses to return to the caller instead of throwing on. */ + allowStatuses: readonly number[] = [], +): 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 && !allowStatuses.includes(response.status)) { + const body = await response.text(); + throw new Error(`GitHub request failed (${response.status}) for ${url}: ${body}`); + } + return response; +} + +async function fetchLinkedIssues( + token: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + const query = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 20) { + nodes { + number + state + repository { nameWithOwner } + labels(first: 50) { nodes { name } } + } + } + } + } + }`; + const response = await githubFetch("https://api.github.com/graphql", token, { + method: "POST", + body: JSON.stringify({ + query, + variables: { owner, repo, number: prNumber }, + }), + }); + const payload = (await response.json()) as { + errors?: Array<{ message: string }>; + data?: { + repository?: { + pullRequest?: { + closingIssuesReferences?: { nodes?: GraphQLIssueNode[] }; + }; + }; + }; + }; + if (payload.errors?.length) { + throw new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`); + } + const nodes = payload.data?.repository?.pullRequest?.closingIssuesReferences?.nodes ?? []; + return nodes.map((node) => ({ + repository: node.repository.nameWithOwner, + number: node.number, + state: node.state, + labels: node.labels.nodes.map((label) => label.name), + })); +} + +interface RestPullRequest { + number: number; + author_association: string; + user: { login: string; type: string } | null; +} + +async function fetchOpenPullRequests( + token: string, + owner: string, + repo: string, +): Promise { + const prs: OpenPr[] = []; + for (let page = 1; ; page++) { + const url = + `https://api.github.com/repos/${owner}/${repo}/pulls` + + `?state=open&per_page=100&page=${page}`; + const response = await githubFetch(url, token); + const batch = (await response.json()) as RestPullRequest[]; + for (const pr of batch) { + prs.push({ + number: pr.number, + authorLogin: pr.user?.login ?? "", + authorAssociation: pr.author_association, + isBot: pr.user?.type === "Bot", + }); + } + if (batch.length < 100) { + break; + } + } + return prs; +} + +/** + * Resolve an author's effective permission on the repository. Reflects access + * granted directly or through a team/org membership, so it recognises private + * org members that `author_association` reports only as `CONTRIBUTOR`. This + * endpoint needs just `Metadata: read` (covered by the workflow's + * `contents: read`). + * + * A 404 means the author is not a collaborator at all — the common case for + * external fork contributors, who are exactly who the gate targets — so it maps + * to `undefined` (external) rather than throwing. Other failures still throw so + * a transient API error aborts the run without wrongly closing PRs. + */ +export async function fetchAuthorPermission( + token: string, + owner: string, + repo: string, + login: string, +): Promise { + if (!login) { + return undefined; + } + const url = + `https://api.github.com/repos/${owner}/${repo}/collaborators/` + + `${encodeURIComponent(login)}/permission`; + const response = await githubFetch(url, token, {}, [404]); + if (response.status === 404) { + return undefined; + } + const payload = (await response.json()) as { permission?: string }; + return payload.permission; +} + +/** + * Build the "comment then close" action for a repo. In dry-run mode it logs the + * intended action instead of mutating the PR. + */ +function makeCloser( + token: string, + base: string, + dryRun: boolean, +): (prNumber: number, message: string) => Promise { + return async (prNumber, message) => { + if (dryRun) { + console.log(`[dry-run] would comment on and close PR #${prNumber}`); + return; + } + await githubFetch(`${base}/issues/${prNumber}/comments`, token, { + method: "POST", + body: JSON.stringify({ body: message }), + }); + await githubFetch(`${base}/issues/${prNumber}`, token, { + method: "PATCH", + body: JSON.stringify({ state: "closed", state_reason: "not_planned" }), + }); + }; +} + +/** Single-PR mode: react to the PR carried by a `pull_request_target` event. */ +async function runSinglePr( + token: string, + owner: string, + repo: string, + repository: string, +): Promise { + const prNumber = Number(requireEnv("PR_NUMBER")); + const authorAssociation = process.env.PR_AUTHOR_ASSOCIATION ?? "NONE"; + const authorLogin = process.env.PR_AUTHOR_LOGIN ?? ""; + const isBot = (process.env.PR_AUTHOR_TYPE ?? "User") === "Bot"; + + // Resolve maintainer status from the effective repository permission unless a + // cheaper signal already settles it. `author_association` only exposes public + // org membership, so a private member must be confirmed via permission. + let permission: string | undefined; + let isInternal = isBot || INTERNAL_ASSOCIATIONS.has(authorAssociation); + if (!isInternal) { + permission = await fetchAuthorPermission(token, owner, repo, authorLogin); + isInternal = isInternalAuthor(authorAssociation, permission); + } + + const linkedIssues = await fetchLinkedIssues(token, owner, repo, prNumber); + const result = evaluateGate({ + repository, + isInternal, + isBot, + linkedIssues, + }); + + console.log( + `Contribution gate for PR #${prNumber}: pass=${result.pass} reason=${result.reason} ` + + `(author_association=${authorAssociation}, permission=${permission ?? "n/a"}, ` + + `bot=${isBot}, linked_issues=${linkedIssues.length})`, + ); + + if (result.pass || !result.message) { + return; + } + + const base = `https://api.github.com/repos/${owner}/${repo}`; + await makeCloser(token, base, false)(prNumber, result.message); + console.log(`Closed PR #${prNumber} (reason=${result.reason}).`); +} + +/** All-PRs mode: sweep every open PR, for on-demand `workflow_dispatch` runs. */ +async function runSweep( + token: string, + owner: string, + repo: string, + repository: string, + dryRun: boolean, +): Promise { + const base = `https://api.github.com/repos/${owner}/${repo}`; + const io: GateIo = { + listOpenPrs: () => fetchOpenPullRequests(token, owner, repo), + fetchPermission: (login) => fetchAuthorPermission(token, owner, repo, login), + fetchLinkedIssues: (prNumber) => fetchLinkedIssues(token, owner, repo, prNumber), + closePr: makeCloser(token, base, dryRun), + }; + + const entries = await evaluateAllOpenPrs(io, repository); + const failing = entries.filter((entry) => !entry.result.pass); + console.log( + `Contribution gate sweep: ${entries.length} open PR(s) evaluated, ` + + `${failing.length} ${dryRun ? "would be " : ""}closed.`, + ); + for (const entry of entries) { + console.log(` PR #${entry.number}: pass=${entry.result.pass} reason=${entry.result.reason}`); + } +} + +async function main(): Promise { + const token = requireEnv("GITHUB_TOKEN"); + const repository = requireEnv("GITHUB_REPOSITORY"); + const [owner, repo] = repository.split("/"); + + const allMode = process.env.GATE_MODE === "all" || process.argv.includes("--all"); + if (allMode) { + const dryRun = /^(1|true)$/i.test(process.env.DRY_RUN ?? ""); + await runSweep(token, owner!, repo!, repository, dryRun); + } else { + await runSinglePr(token, owner!, repo!, repository); + } +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/workflows/api-package-sync.yml b/.github/workflows/api-package-sync.yml index bb1117b38a..cdba3fd3c7 100644 --- a/.github/workflows/api-package-sync.yml +++ b/.github/workflows/api-package-sync.yml @@ -13,7 +13,7 @@ jobs: name: Sync API package runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml index 8faf9d0377..d4024a5ae8 100644 --- a/.github/workflows/build-cli-artifacts.yml +++ b/.github/workflows/build-cli-artifacts.yml @@ -66,7 +66,7 @@ jobs: SUPABASE_CLI_REQUIRE_SIGNING: "1" steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref }} persist-credentials: false @@ -81,10 +81,18 @@ jobs: run: go mod download -x - name: Install nfpm + env: + NFPM_VERSION: "2.47.0" + NFPM_SHA256: "0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783" run: | - echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list - sudo apt-get update - sudo apt-get install -y nfpm + set -euo pipefail + asset="nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" + curl -fsSL -o /tmp/nfpm.tar.gz \ + "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/${asset}" + echo "${NFPM_SHA256} /tmp/nfpm.tar.gz" | sha256sum -c + tar -xzf /tmp/nfpm.tar.gz -C /tmp nfpm + sudo install -m 0755 /tmp/nfpm /usr/local/bin/nfpm + nfpm --version - name: Install rcodesign env: diff --git a/.github/workflows/cli-go-api-sync.yml b/.github/workflows/cli-go-api-sync.yml index 4c7d3a7fbe..7efdb8a4c4 100644 --- a/.github/workflows/cli-go-api-sync.yml +++ b/.github/workflows/cli-go-api-sync.yml @@ -14,11 +14,11 @@ jobs: name: Sync API Types runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: apps/cli-go/go.mod cache: true diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 0d497ffc99..ed7f07b7a9 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -19,11 +19,11 @@ jobs: name: Test runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: apps/cli-go/go.mod cache: true @@ -74,11 +74,11 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4 + - uses: jdx/mise-action@dad1bfd3df957f44999b559dd69dc1671cb4e9ea # v4 with: version: 2026.7.0 install: true @@ -97,10 +97,10 @@ jobs: name: Start runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: apps/cli-go/go.mod cache: true @@ -123,10 +123,10 @@ jobs: 'pull_request' && !github.event.pull_request.head.repo.fork) }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: apps/cli-go/go.mod cache: true @@ -140,11 +140,11 @@ jobs: name: Codegen runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: apps/cli-go/go.mod cache: true diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index 3a7c41cbed..b1e1adf123 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -61,13 +61,13 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -95,7 +95,7 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: category: "/language:${{matrix.language}}" defaults: diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index 3c9484e266..2844079487 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -34,19 +34,19 @@ jobs: run: | echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: public.ecr.aws - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Mirror image env: SOURCE_IMAGE: docker.io/${{ github.event.client_payload.image || inputs.image }} diff --git a/.github/workflows/cli-go-mirror.yml b/.github/workflows/cli-go-mirror.yml index 1e5d3224e9..cb302c411f 100644 --- a/.github/workflows/cli-go-mirror.yml +++ b/.github/workflows/cli-go-mirror.yml @@ -23,10 +23,10 @@ jobs: tags: ${{ steps.list.outputs.tags }} curr: ${{ steps.curr.outputs.tags }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: apps/cli-go/go.mod cache: true diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/cli-go-pg-prove.yml index 14202acc5c..323758c1c0 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/cli-go-pg-prove.yml @@ -11,8 +11,8 @@ jobs: outputs: image_tag: supabase/pg_prove:${{ steps.version.outputs.pg_prove }} steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: load: true context: https://github.com/horrendo/pg_prove.git @@ -42,15 +42,15 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: endpoint: builders - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: push: true context: https://github.com/horrendo/pg_prove.git @@ -66,8 +66,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/cli-go-publish-migra.yml index 5220a0562d..11b6437666 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/cli-go-publish-migra.yml @@ -11,8 +11,8 @@ jobs: outputs: image_tag: supabase/migra:${{ steps.version.outputs.migra }} steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: load: true context: https://github.com/djrobstep/migra.git @@ -42,15 +42,15 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: endpoint: builders - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: push: true context: https://github.com/djrobstep/migra.git @@ -66,8 +66,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/cli-go-tag-pkg.yml b/.github/workflows/cli-go-tag-pkg.yml index a07f87f1ae..9185ad5771 100644 --- a/.github/workflows/cli-go-tag-pkg.yml +++ b/.github/workflows/cli-go-tag-pkg.yml @@ -16,7 +16,7 @@ jobs: name: Create pkg tag runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: develop fetch-depth: 0 diff --git a/.github/workflows/contribution-gate.yml b/.github/workflows/contribution-gate.yml new file mode 100644 index 0000000000..68d3f3b639 --- /dev/null +++ b/.github/workflows/contribution-gate.yml @@ -0,0 +1,75 @@ +name: Contribution Gate + +on: + pull_request_target: + types: + - opened + - reopened + - edited + # Manual sweep over every open PR — e.g. after bulk-applying the + # `open-for-contribution` label. Set `dry_run` to log decisions without + # commenting or closing. + workflow_dispatch: + inputs: + dry_run: + description: "Evaluate and log decisions only; do not comment or close" + type: boolean + default: false + +permissions: + pull-requests: write + issues: read + contents: read + +concurrency: + # Per-PR for pull_request_target; unique per run for manual sweeps (which + # carry no pull_request payload) so two sweeps never cancel each other. + group: contribution-gate-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + gate-single: + name: Contribution Gate + runs-on: ubuntu-latest + # Exempt maintainers/collaborators and bots. External contributors are gated. + if: > + github.event_name == 'pull_request_target' && + github.event.pull_request.author_association != 'OWNER' && + github.event.pull_request.author_association != 'MEMBER' && + github.event.pull_request.author_association != 'COLLABORATOR' && + github.event.pull_request.user.type != 'Bot' + steps: + # 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 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.13" + - name: Evaluate contribution gate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR_AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + run: bun .github/scripts/contribution-gate.ts + + gate-all: + name: Contribution Gate (all open PRs) + runs-on: ubuntu-latest + 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 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.13" + - name: Evaluate contribution gate across all open PRs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GATE_MODE: all + DRY_RUN: ${{ inputs.dry_run || 'false' }} + run: bun .github/scripts/contribution-gate.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5291036c16..f371693622 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,7 +13,7 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index efdb4a8374..2d402865ef 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -106,7 +106,7 @@ jobs: CLI_HARNESS_TARGET: ${{ matrix.target }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index 40fa3be500..975e2fa618 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -40,7 +40,7 @@ jobs: missing: ${{ steps.detect.outputs.missing }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -50,7 +50,7 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Log in to ghcr.io - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index e7486fc5f7..afbc6ec537 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -48,7 +48,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 79a3175805..c46250985e 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -104,7 +104,7 @@ jobs: VERSION: ${{ inputs.version }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -160,7 +160,7 @@ jobs: - name: Setup QEMU for cross-platform Docker if: runner.os == 'Linux' - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Verify linux/arm64 emulation is registered if: runner.os == 'Linux' @@ -240,7 +240,7 @@ jobs: VERSION: ${{ inputs.version }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -300,7 +300,7 @@ jobs: permission-workflows: write - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true fetch-depth: 0 @@ -389,7 +389,7 @@ jobs: done - name: Create draft GitHub Release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: v${{ inputs.version }} name: v${{ inputs.version }} @@ -488,7 +488,7 @@ jobs: VERSION: ${{ inputs.version }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -551,7 +551,7 @@ jobs: VERSION: ${{ inputs.version }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/sync-stack-service-versions.yml b/.github/workflows/sync-stack-service-versions.yml index 0650e3e233..d54cb756d4 100644 --- a/.github/workflows/sync-stack-service-versions.yml +++ b/.github/workflows/sync-stack-service-versions.yml @@ -23,7 +23,7 @@ jobs: if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.ref }} persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 571fb5fc62..df96410d79 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -58,7 +58,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4312076c56..0394f608a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,18 @@ Bun monorepo for exploring the next generation of the Supabase CLI and local development stack. +## Contribution workflow + +Before you open a pull request: + +1. **Open an issue first**, using one of the [issue templates](https://github.com/supabase/cli/issues/new/choose). +2. **Wait for maintainer triage.** A maintainer categorizes the issue (`✨ Feature`, `🐛 Bug`, or `📘 Docs`) and adds the **`open-for-contribution`** label once it is ready to be worked on. +3. **Open a pull request only after the `open-for-contribution` label is set**, and link the issue with a closing keyword (for example `Closes #123`). + +Until the `open-for-contribution` label is present, the issue is still in triage, so work should not start and a pull request should not be opened. + +Pull requests from external contributors that do not follow this workflow are commented on and closed automatically by the [Contribution Gate](.github/workflows/contribution-gate.yml). Supabase members are exempt, so they can work from Linear tickets that are not public on GitHub. Maintainers: see [`.github/MAINTAINERS.md`](.github/MAINTAINERS.md). + ## Setup ### Tool versions @@ -258,7 +270,7 @@ Test a real end-to-end publish and install of the CLI against a local npm regist pnpm local-registry ``` -This starts Verdaccio on `http://localhost:4873`, creates a publish user, and redirects the global `npm` and `pnpm` registry config to `localhost`. Press **Ctrl+C** when done — the original registry settings are restored automatically. +This starts Verdaccio on `http://localhost:4873` and creates a publish user. Your global `npm` and `pnpm` registry config is never modified — every command that talks to the local registry passes `--registry` explicitly. Press **Ctrl+C** when done. **Terminal 2 — build and publish:** @@ -298,7 +310,7 @@ supabase --version | `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` (legacy only) | Install Go from https://go.dev/dl/ | | `Error: Go CLI source not found` (legacy only) | Run `pnpm repos:install` to clone `apps/cli-go` | -| Registry not restored after crash | Run `npm config set registry https://registry.npmjs.org/` and `pnpm config set registry https://registry.npmjs.org/` | +| `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` | ## Using Nx diff --git a/README.md b/README.md index 1e0b1d528e..9652d3738b 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,11 @@ pnpm repos:install ## Contributing -We love focused pull requests with a clear problem, a small surface area, and tests that match the user-facing behavior. Before opening a PR, run the checks for the workspace you touched. +We love focused pull requests with a clear problem, a small surface area, and tests that match the user-facing behavior. + +Open an issue first and wait for a maintainer to add the `open-for-contribution` label before starting work — external pull requests that don't link a labeled, open issue are closed automatically. See [CONTRIBUTING.md](./CONTRIBUTING.md#contribution-workflow) for the full workflow. + +Before opening a PR, run the checks for the workspace you touched. ```sh pnpm check:all diff --git a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts new file mode 100644 index 0000000000..9c2ec6f8e7 --- /dev/null +++ b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts @@ -0,0 +1,81 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; +import { TARGET } from "../env.ts"; +import { testLive } from "./live-context.ts"; + +// Real-backend live coverage for the native `db start` / `db reset` ports. +// +// `db start` / `db reset` live only in the `go` reference and the `ts-legacy` +// port (the `next` shell has no `db` group), so skip the `ts-next` target. +// +// The live suite runs serially (`fileParallelism: false`, `maxWorkers: 1`), so the +// destructive remote reset below is safe against the throwaway per-run project. + +// --- Local leg: db start + db reset --local against the real Docker socket ----- +// Exercises the hidden `db __db-bootstrap` Go seam end-to-end — the boundary the +// in-process integration suites mock. The start → already-running → reset cycle +// runs in one test so it shares a single booted stack, and `finally` stops it +// (legacy proxies `stop` to Go) so the run never leaves containers behind. +describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local Docker)", () => { + testLive( + "db start boots, is idempotent, and db reset --local recreates", + { timeout: 600_000 }, + async ({ run }) => { + try { + const start = await run(["db", "start"]); + expect(start.exitCode, start.stderr).toBe(0); + // Go tees bootstrap progress to stderr (mode-independent). + expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); + + // Second start is a no-op: the db is already running, exit 0. + const again = await run(["db", "start"]); + expect(again.exitCode, again.stderr).toBe(0); + expect(`${again.stdout}${again.stderr}`).toMatch(/already[\s-]running/i); + + // Local reset recreates the container and prints the git-branch line. + const reset = await run(["db", "reset", "--local"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("on branch "); + } finally { + await run(["stop", "--no-backup"]).catch(() => undefined); + } + }, + ); +}); + +// --- Remote leg: db reset against the staging project over the session pooler --- +// Exercises the native remote reset path (drop user schemas → apply local +// migrations → seed) against a real Postgres, no Docker. `--yes` auto-accepts the +// confirmation prompt (the non-interactive default is decline). Mutates the +// throwaway project's schema — deleted on teardown. The IPv4 session pooler +// `dbUrl` is used because the direct host is IPv6-only and unreachable from +// IPv4-only CI runners. +describe.skipIf(TARGET === "ts-next")("db reset (live, remote session pooler)", () => { + testLive( + "resets the remote schema and re-applies a local migration", + { timeout: 600_000 }, + async ({ run, dbUrl, workspace }) => { + const migrations = join(workspace.path, "supabase", "migrations"); + mkdirSync(migrations, { recursive: true }); + writeFileSync( + join(migrations, "20240101000000_e2e_reset.sql"), + "create table if not exists e2e_reset (id int);\n", + ); + + const reset = await run(["db", "reset", "--db-url", dbUrl, "--yes"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("Resetting remote database"); + // A real connection failure must never be mistaken for a benign outcome. + expect(`${reset.stdout}${reset.stderr}`, "db reset hit a connection error").not.toMatch( + /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, + ); + + // The migration history shows the re-applied version → proves the drop + + // migrate ran against the remote database. + const listed = await run(["migration", "list", "--db-url", dbUrl]); + expect(listed.exitCode, listed.stderr).toBe(0); + expect(listed.stdout).toContain("20240101000000"); + }, + ); +}); diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index 0e3dbc932f..45b02ef3bc 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -45,7 +45,10 @@ describe("migrations", () => { testBehaviour("exits non-zero without name argument", async ({ run }) => { const result = await run(["migration", "new"]); expect(result.exitCode).not.toBe(0); - expect(result.stdout).toContain("migration name"); + // CLI-1901: a missing positional argument's usage block now prints to + // stderr (never stdout) instead of being duplicated across both. + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("migration name"); }); }); @@ -90,7 +93,11 @@ describe("migrations", () => { 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); - expect(result.stderr).toContain("--status"); + // CLI-1901: a missing required flag now drops the vendored library's + // duplicate usage dump entirely (Go's cobra suppresses usage for this + // case too), leaving only this repo's existing Go-parity error line, + // which spells the flag name without its `--` prefix. + expect(result.stderr).toContain('"status" not set'); }); testBehaviour("exits non-zero on connection refused", async ({ run }) => { diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index df04364e44..66df4311cc 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "os" @@ -270,6 +271,71 @@ var ( }, } + bootstrapMode string + bootstrapSqlPaths []string + bootstrapFromBackup string + bootstrapVersion string + bootstrapNoSeed bool + + // dbBootstrapCmd is a hidden seam used by the native-TypeScript `db start` and + // `db reset --local` commands to drive the container-bootstrap primitives that + // are not yet ported to TypeScript: creating/recreating the local Postgres + // container, applying the initial schema, and the storage health gate. The TS + // caller orchestrates everything else (the "already running?" check and its + // message, version/last resolution, bucket seeding, the git-branch "Finished…" + // line, telemetry, and --output-format shaping); the seam stays in Go only for + // the Docker lifecycle. It mirrors the existing db __shadow seam: it carries no + // db-url/local/linked target flags, so it loads supabase/config.toml explicitly + // (the root PersistentPreRunE only loads it when a target flag is set). Progress + // goes to stderr; the only stdout output is a single machine-parseable marker + // for --mode await-storage ("ready" or "absent"). + dbBootstrapCmd = &cobra.Command{ + Use: "__db-bootstrap", + Hidden: true, + Short: "Internal: container bootstrap for the native db start / db reset commands", + RunE: func(cmd *cobra.Command, args []string) error { + fsys := afero.NewOsFs() + if err := flags.LoadConfig(fsys); err != nil { + return err + } + switch bootstrapMode { + case "start": + // Mirror start.Run minus the "already running?" check, which the TS + // caller performs (and prints "Postgres database is already running."). + if err := start.StartDatabase(cmd.Context(), bootstrapFromBackup, fsys, os.Stderr); err != nil { + if rmErr := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); rmErr != nil { + fmt.Fprintln(os.Stderr, rmErr) + } + return err + } + return nil + case "recreate": + // The PG14/PG15 container-recreate half of local db reset. The TS + // caller has already printed "Resetting local database…" and validated + // the flags. Apply the same seed handling as `db reset` (dbResetCmd): + // `--no-seed` disables the seed, `--sql-paths` overrides the seed paths, + // before MigrateAndSeed runs inside the recreate. + if err := applyDbResetSeedFlags(bootstrapNoSeed, bootstrapSqlPaths); err != nil { + return err + } + return reset.RecreateLocalDatabase(cmd.Context(), bootstrapVersion, fsys) + case "await-storage": + ready, err := reset.AwaitStorageReady(cmd.Context()) + if err != nil { + return err + } + if ready { + fmt.Println("ready") + } else { + fmt.Println("absent") + } + return nil + default: + return fmt.Errorf("unknown bootstrap mode: %s", bootstrapMode) + } + }, + } + dbRemoteCmd = &cobra.Command{ Hidden: true, Use: "remote", @@ -620,6 +686,14 @@ func init() { shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") dbCmd.AddCommand(dbShadowCmd) + // Build hidden container-bootstrap seam command (native db start / db reset) + bootstrapFlags := dbBootstrapCmd.Flags() + bootstrapFlags.StringVar(&bootstrapMode, "mode", "start", "Bootstrap mode: start, recreate, or await-storage.") + bootstrapFlags.StringVar(&bootstrapFromBackup, "from-backup", "", "Path to a logical backup file (start mode).") + bootstrapFlags.StringVar(&bootstrapVersion, "version", "", "Reset up to the specified version (recreate mode).") + bootstrapFlags.BoolVar(&bootstrapNoSeed, "no-seed", false, "Skip the seed script after recreate (recreate mode).") + bootstrapFlags.StringArrayVar(&bootstrapSqlPaths, "sql-paths", nil, "Override [db.seed].sql_paths for the recreate (recreate mode).") + dbCmd.AddCommand(dbBootstrapCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") diff --git a/apps/cli-go/docs/supabase/db/diff.md b/apps/cli-go/docs/supabase/db/diff.md index 822c752485..0c0cf05a4d 100644 --- a/apps/cli-go/docs/supabase/db/diff.md +++ b/apps/cli-go/docs/supabase/db/diff.md @@ -10,6 +10,8 @@ By default, all schemas in the target database are diffed. Use the `--schema pub Projects created by a recent `supabase init` default to the pg-delta diff engine (`[experimental.pgdelta] enabled = true` in `config.toml`). Existing projects are unaffected and keep using migra unless they opt in. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--use-migra` for a single run. +With the pg-delta engine the diff SQL is formatted by default with the same settings the declarative export uses (uppercase keywords, wrapped at a max width of 180, indented and column-aligned); execution-aware transaction boundaries are preserved as per-unit header comments in the output. Configure overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw, unformatted statements. + While the diff command is able to capture most schema changes, there are cases where it is known to fail. Currently, this could happen if you schema contains: - Changes to publication diff --git a/apps/cli-go/docs/supabase/db/pull.md b/apps/cli-go/docs/supabase/db/pull.md index ff35019715..e10c0679f7 100644 --- a/apps/cli-go/docs/supabase/db/pull.md +++ b/apps/cli-go/docs/supabase/db/pull.md @@ -12,6 +12,10 @@ If no entries exist in the migration history table, the default diff engine uses Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. On initial pull, pg-delta replaces `pg_dump` and produces the full migration from the shadow diff alone. Pass `--declarative` to switch to the declarative pg-delta export workflow instead. +pg-delta plans are execution-aware: when a plan crosses a transaction boundary — for example `ALTER TYPE ... ADD VALUE` followed by a statement that uses the new enum value, which cannot run in the same transaction — `db pull` writes one ordered migration file per plan unit instead of a single file (for example `_remote_schema_schema_changes.sql` and `_remote_schema_after_enum_values.sql`), each recorded in the migration history. The common case (a single unit) still produces exactly one `_remote_schema.sql` file. + +By default the emitted SQL is formatted with the same settings the declarative export uses (uppercase keywords, wrapped at a max width of 180, indented and column-aligned). Configure overrides with `[experimental.pgdelta] format_options` in `config.toml`, or set `format_options = "null"` to opt out and emit raw, unformatted statements. + When `[experimental.pgdelta] enabled = true` (the default for projects created by a recent `supabase init`), the migration-file `db pull` workflow uses pg-delta for the shadow diff step by default; it does not switch to declarative output. Existing projects without the section are unaffected and keep using migra. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--diff-engine migra` for a single run. When pulling from a remote database with `--db-url`, prefer a direct connection (`db..supabase.co:5432`) over the connection pooler so pg-delta can introspect the full catalog reliably. diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 82b8ea4526..25a5818bf1 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -20,7 +20,7 @@ require ( github.com/docker/go-connections v0.7.0 github.com/docker/go-units v0.5.0 github.com/fsnotify/fsnotify v1.10.1 - github.com/getsentry/sentry-go v0.47.0 + github.com/getsentry/sentry-go v0.48.0 github.com/go-errors/errors v1.5.1 github.com/go-git/go-git/v5 v5.19.1 github.com/go-playground/validator/v10 v10.30.3 @@ -42,23 +42,23 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.17.2 + github.com/posthog/posthog-go v1.19.0 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.11.1 - github.com/stripe/pg-schema-diff v1.0.5 + github.com/stripe/pg-schema-diff v1.0.7 github.com/supabase/cli/pkg v1.0.0 github.com/tidwall/jsonc v0.3.3 github.com/withfig/autocomplete-tools/packages/cobra v1.2.0 github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel v1.44.0 - golang.org/x/mod v0.37.0 - golang.org/x/net v0.56.0 + golang.org/x/mod v0.38.0 + golang.org/x/net v0.57.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/term v0.44.0 - google.golang.org/grpc v1.81.1 + golang.org/x/term v0.45.0 + google.golang.org/grpc v1.82.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -189,7 +189,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect - github.com/getkin/kin-openapi v0.131.0 // indirect + github.com/getkin/kin-openapi v0.135.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 @@ -301,7 +301,7 @@ require ( 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/mailru/easyjson v0.9.0 // indirect + github.com/mailru/easyjson v0.9.1 // 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 @@ -350,10 +350,10 @@ require ( 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.4.1 // indirect - github.com/oapi-codegen/runtime v1.4.2 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 // indirect + github.com/oapi-codegen/runtime v1.6.0 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.9 // 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 @@ -398,7 +398,8 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/sonatard/noctx v0.5.1 // indirect github.com/sourcegraph/go-diff v0.8.0 // indirect - github.com/speakeasy-api/openapi-overlay v0.9.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 @@ -421,6 +422,7 @@ require ( 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/woodsbury/decimal128 v1.4.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect @@ -458,13 +460,13 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect + golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/tools v0.47.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 @@ -472,7 +474,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gotest.tools/gotestsum v1.12.2 // indirect honnef.co/go/tools v0.7.0 // indirect k8s.io/api v0.34.1 // indirect diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index f929d36494..d7fb2d5bee 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -371,10 +371,10 @@ 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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/getkin/kin-openapi v0.131.0 h1:NO2UeHnFKRYhZ8wg6Nyh5Cq7dHk4suQQr72a4pMrDxE= -github.com/getkin/kin-openapi v0.131.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= -github.com/getsentry/sentry-go v0.47.0 h1:AnSMSyrYA5qZCIN/2xpgAAwv63sVULV+vBq37ajouc8= -github.com/getsentry/sentry-go v0.47.0/go.mod h1:h+b4VHpKnK7aUXB5wc+KDnPgp9ZtfliRD4eV85FbiSA= +github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= +github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= +github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY= +github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss= 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= @@ -765,8 +765,8 @@ github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i 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/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 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= @@ -894,14 +894,14 @@ 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.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q= -github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8= -github.com/oapi-codegen/runtime v1.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE= -github.com/oapi-codegen/runtime v1.4.2/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +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.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= +github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= +github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= 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= @@ -970,8 +970,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.17.2 h1:w0TaCAd+Z3WoEgVyR/nlcXlqNN2tpoBfIyxuGssDgCE= -github.com/posthog/posthog-go v1.17.2/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= +github.com/posthog/posthog-go v1.19.0 h1:GsPilbAfQo8dbIo2yheTZ8pbmE5yYW3G289kXrxZxFA= +github.com/posthog/posthog-go v1.19.0/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= 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= @@ -1070,8 +1070,10 @@ github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQyt github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E= github.com/spdx/tools-golang v0.5.5 h1:61c0KLfAcNqAjlg6UNMdkwpMernhw3zVRwDZ2x9XOmk= github.com/spdx/tools-golang v0.5.5/go.mod h1:MVIsXx8ZZzaRWNQpUDhC4Dud34edUYJYecciXgrw5vE= -github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg= -github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc= +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= @@ -1111,8 +1113,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stripe/pg-schema-diff v1.0.5 h1:TNHkiRNMn7ttiBd+YBypAbx9v0SfVls+NQZFtamy1K4= -github.com/stripe/pg-schema-diff v1.0.5/go.mod h1:3IctPaAqm+0LtWw/GiwyRoRlU1/N/+00+eXVk0KZIHs= +github.com/stripe/pg-schema-diff v1.0.7 h1:aVMFqjsnPSeh46hlJnexGPm28nZJUvZK0bEyk6rdFVE= +github.com/stripe/pg-schema-diff v1.0.7/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= @@ -1161,6 +1163,8 @@ github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9N github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= github.com/withfig/autocomplete-tools/packages/cobra v1.2.0 h1:MzD3XeOOSO3mAjOPpF07jFteSKZxsRHvlIcAR9RQzKM= github.com/withfig/autocomplete-tools/packages/cobra v1.2.0/go.mod h1:RoXh7+7qknOXL65uTzdzE1mPxqcPwS7FLCE9K5GfmKo= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -1286,8 +1290,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y 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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= 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= @@ -1303,8 +1307,8 @@ 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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= 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= @@ -1325,8 +1329,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug 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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1339,8 +1343,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ 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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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= @@ -1380,16 +1384,16 @@ 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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +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= @@ -1399,8 +1403,8 @@ 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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= 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= @@ -1422,8 +1426,8 @@ golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0t 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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 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= @@ -1441,8 +1445,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go. 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.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= 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= @@ -1484,7 +1488,6 @@ 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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 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= diff --git a/apps/cli-go/internal/db/advisors/templates/lints.sql b/apps/cli-go/internal/db/advisors/templates/lints.sql index c8a54a3a7f..c8c96de64c 100644 --- a/apps/cli-go/internal/db/advisors/templates/lints.sql +++ b/apps/cli-go/internal/db/advisors/templates/lints.sql @@ -117,7 +117,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) -- Exclude self and c.relname <> '0002_auth_users_exposed' -- There are 3 insecure configurations @@ -610,7 +610,7 @@ where or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) @@ -705,7 +705,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' )) @@ -836,7 +836,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) @@ -879,7 +879,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) @@ -966,7 +966,7 @@ where and n.nspname = 'pgmq' -- tables in the pgmq schema and c.relname like 'q_%' -- only queue tables -- Constant requirements - and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))) + and 'pgmq_public' = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))) union all ( with constants as ( @@ -1198,7 +1198,7 @@ exposed_tables as ( pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index c79df6289f..b84087bf9f 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -204,7 +204,7 @@ func SyncToMigrations(ctx context.Context, schema []string, file string, noCache if len(strings.TrimSpace(file)) == 0 { file = "declarative_sync" } - if err := diff.SaveDiff(result.DiffSQL, file, fsys); err != nil { + if err := diff.SaveDiff(diff.DatabaseDiff{SQL: result.DiffSQL}, file, fsys); err != nil { return err } if len(result.DropWarnings) > 0 { diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index 85c9c09c9c..32fabb2c21 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -38,7 +38,7 @@ func Run(ctx context.Context, schema []string, file string, config pgconn.Config 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(out, file, fsys); err != nil { + if err := SaveDiff(result, file, fsys); err != nil { return err } drops := findDropStatements(out) @@ -225,22 +225,31 @@ func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w } else { fmt.Fprintln(w, "Diffing schemas...") } - if IsPgDeltaDebugEnabled() && usePgDelta { - // Capture the shadow baseline catalog and edge-runtime stderr so an - // empty diff can be inspected later. DiffPgDeltaRefDetailed mirrors the - // pg-delta differ but additionally surfaces stderr, which differ() drops. - debugCapture := &PgDeltaDebugCapture{} - if snapshot, exportErr := exportCatalogPgDelta(ctx, utils.ToPostgresURL(shadowConfig), "postgres", options...); exportErr == nil { - debugCapture.SourceCatalog = snapshot - } else { - fmt.Fprintf(w, "Warning: failed to export shadow pg-delta catalog: %v\n", exportErr) + if usePgDelta { + // pg-delta always goes through the diffPgDeltaRefDetailed seam so callers get + // the execution-aware per-unit files (db pull writes one migration file each); + // db diff/declarative flatten them back via SQL. This mirrors the config-based + // differ (DiffPgDelta) exactly, so it is safe to bypass the injected differ() + // here — differ() remains the migra engine path below. + var debugCapture *PgDeltaDebugCapture + if IsPgDeltaDebugEnabled() { + // Capture the shadow baseline catalog and edge-runtime stderr so an + // empty diff can be inspected later. + debugCapture = &PgDeltaDebugCapture{} + if snapshot, exportErr := exportCatalogPgDelta(ctx, utils.ToPostgresURL(shadowConfig), "postgres", options...); exportErr == nil { + debugCapture.SourceCatalog = snapshot + } else { + fmt.Fprintf(w, "Warning: failed to export shadow pg-delta catalog: %v\n", exportErr) + } } - result, err := DiffPgDeltaRefDetailed(ctx, utils.ToPostgresURL(shadowConfig), utils.ToPostgresURL(config), schema, pgDeltaFormatOptions(), options...) + result, err := diffPgDeltaRefDetailed(ctx, utils.ToPostgresURL(shadowConfig), utils.ToPostgresURL(config), schema, pgDeltaFormatOptions(), options...) if err != nil { return DatabaseDiff{}, err } - debugCapture.Stderr = result.Stderr - return DatabaseDiff{SQL: result.SQL, Debug: debugCapture}, nil + if debugCapture != nil { + debugCapture.Stderr = result.Stderr + } + return DatabaseDiff{SQL: joinPgDeltaFiles(result.Files), Files: result.Files, Debug: debugCapture}, nil } output, err := differ(ctx, shadowConfig, config, schema, options...) if err != nil { diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go index 92fd01164b..aff3242699 100644 --- a/apps/cli-go/internal/db/diff/diff_test.go +++ b/apps/cli-go/internal/db/diff/diff_test.go @@ -224,12 +224,23 @@ func TestRun(t *testing.T) { Reply("CREATE FUNCTION"). Query(tableSQL). Reply("CREATE TABLE") + // pg-delta bypasses the injected DiffFunc and runs the real edge-runtime + // pipeline, so stub the seam DiffDatabase uses (mirrors exportCatalogPgDelta). + // The migra differ must never be reached on this path. + originalDiffPgDelta := diffPgDeltaRefDetailed + t.Cleanup(func() { diffPgDeltaRefDetailed = originalDiffPgDelta }) diffCalled := false - differ := func(_ context.Context, _, target pgconn.Config, schema []string, _ ...func(*pgx.ConnConfig)) (string, error) { + diffPgDeltaRefDetailed = func(_ context.Context, _, targetRef string, schema []string, _ string, _ ...func(*pgx.ConnConfig)) (PgDeltaDiffResult, error) { diffCalled = true - assert.Equal(t, "contrib_regression", target.Database) + assert.Contains(t, targetRef, "contrib_regression") assert.Equal(t, []string{"public"}, schema) - return generated, nil + return PgDeltaDiffResult{ + Files: []PgDeltaPlanFile{{Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: generated}}, + }, nil + } + differ := func(context.Context, pgconn.Config, pgconn.Config, []string, ...func(*pgx.ConnConfig)) (string, error) { + t.Fatal("migra differ must not be called on the pg-delta path") + return "", nil } localConfig := pgconn.Config{ Host: utils.Config.Hostname, diff --git a/apps/cli-go/internal/db/diff/pgadmin.go b/apps/cli-go/internal/db/diff/pgadmin.go index d53cd4660e..9f1a692971 100644 --- a/apps/cli-go/internal/db/diff/pgadmin.go +++ b/apps/cli-go/internal/db/diff/pgadmin.go @@ -5,6 +5,7 @@ import ( _ "embed" "fmt" "os" + "time" "github.com/jackc/pgconn" "github.com/spf13/afero" @@ -17,13 +18,26 @@ import ( 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(out, file string, fsys afero.Fs) error { +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 + // A pg-delta plan that crosses a transaction boundary yields more than one + // ordered unit; writing them into a single migration file would later fail + // when `db push`/`reset` applies it as one transaction. Write one migration + // file per unit in that case (Go's `WritePgDeltaMigrations`). The migra / + // pgadmin engines and single-unit pg-delta plans keep the exact single-file + // path, byte-identical to before. + if len(result.Files) > 1 { + if _, err := WritePgDeltaMigrations(result.Files, time.Now(), file, fsys); err != nil { + return err + } + } else { + path := new.GetMigrationPath(utils.GetCurrentTimestamp(), file) + if err := utils.WriteFile(path, []byte(out), fsys); err != nil { + return err + } } fmt.Fprintln(os.Stderr, warnDiff) } else { @@ -44,7 +58,7 @@ func RunPgAdmin(ctx context.Context, schema []string, file string, config pgconn return err } - return SaveDiff(output, file, fsys) + return SaveDiff(DatabaseDiff{SQL: output}, file, fsys) } var output string diff --git a/apps/cli-go/internal/db/diff/pgadmin_test.go b/apps/cli-go/internal/db/diff/pgadmin_test.go new file mode 100644 index 0000000000..7bf65e7d78 --- /dev/null +++ b/apps/cli-go/internal/db/diff/pgadmin_test.go @@ -0,0 +1,88 @@ +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)) + // Nothing written when there are no schema changes. + entries, err := afero.ReadDir(fsys, utils.MigrationsDir) + assert.Error(t, err) + assert.Empty(t, entries) + }) + + t.Run("writes a single migration file for a single-unit plan", func(t *testing.T) { + fsys := afero.NewMemMapFs() + files := []PgDeltaPlanFile{ + {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "create table a ();"}, + } + result := DatabaseDiff{SQL: joinPgDeltaFiles(files), Files: files} + require.NoError(t, SaveDiff(result, "my_diff", fsys)) + entries, err := afero.ReadDir(fsys, utils.MigrationsDir) + require.NoError(t, err) + require.Len(t, entries, 1) + // A single-unit plan keeps the plain `_.sql` name and the exact + // diff SQL, byte-identical to the pre-multi-file behavior (no trailing newline + // added, no unit-name suffix). + 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("writes one migration file per unit for a multi-unit plan", func(t *testing.T) { + fsys := afero.NewMemMapFs() + files := []PgDeltaPlanFile{ + {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "alter type mood add value 'ok';"}, + {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, + } + result := DatabaseDiff{SQL: joinPgDeltaFiles(files), Files: files} + require.NoError(t, SaveDiff(result, "my_diff", fsys)) + entries, err := afero.ReadDir(fsys, utils.MigrationsDir) + require.NoError(t, err) + require.Len(t, entries, 2) + // Multi-unit plans split into one ordered file per unit, each suffixed with the + // unit name, so `db push`/`reset` applies each unit as its own transaction. + assert.Regexp(t, `^\d{14}_my_diff_schema_changes\.sql$`, entries[0].Name()) + assert.Regexp(t, `^\d{14}_my_diff_after_enum_values\.sql$`, entries[1].Name()) + }) + + 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 single-unit 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)) + }) + + t.Run("creates nested parent directories for a nested multi-unit name", func(t *testing.T) { + fsys := afero.NewMemMapFs() + files := []PgDeltaPlanFile{ + {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "alter type mood add value 'ok';"}, + {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, + } + result := DatabaseDiff{SQL: joinPgDeltaFiles(files), Files: files} + require.NoError(t, SaveDiff(result, "snapshots/remote", fsys)) + matches, err := afero.Glob(fsys, utils.MigrationsDir+"/*_snapshots/remote_*.sql") + require.NoError(t, err) + require.Len(t, matches, 2) + }) +} diff --git a/apps/cli-go/internal/db/diff/pgdelta.go b/apps/cli-go/internal/db/diff/pgdelta.go index d9d5edfc46..5267f0c6dd 100644 --- a/apps/cli-go/internal/db/diff/pgdelta.go +++ b/apps/cli-go/internal/db/diff/pgdelta.go @@ -43,6 +43,34 @@ type DeclarativeOutput struct { Files []DeclarativeFile `json:"files"` } +// PgDeltaPlanFile is one execution-aware migration unit rendered by pg-delta's +// renderPlanFiles: a numbered SQL file whose header comments record the unit +// number, transaction mode and boundary reason. +type PgDeltaPlanFile struct { + Order int `json:"order"` + Name string `json:"name"` + TransactionMode string `json:"transactionMode"` + SQL string `json:"sql"` +} + +// PgDeltaDiffOutput is the top-level diff envelope emitted by templates/pgdelta.ts. +type PgDeltaDiffOutput struct { + Version int `json:"version"` + Files []PgDeltaPlanFile `json:"files"` +} + +// joinPgDeltaFiles flattens the per-unit files back into a single SQL string for +// callers (db diff, declarative sync) that consume one blob. The per-unit header +// comments keep the transaction boundaries visible in the reviewed output; empty +// files produce an empty string, preserving "no changes" detection. +func joinPgDeltaFiles(files []PgDeltaPlanFile) string { + blocks := make([]string, len(files)) + for i, file := range files { + blocks[i] = file.SQL + } + return strings.Join(blocks, "\n\n") +} + func isPostgresURL(ref string) bool { return strings.HasPrefix(ref, "postgres://") || strings.HasPrefix(ref, "postgresql://") } @@ -101,7 +129,7 @@ func DiffPgDeltaRef(ctx context.Context, sourceRef, targetRef string, schema []s if err != nil { return "", err } - return result.SQL, nil + return joinPgDeltaFiles(result.Files), nil } // DiffPgDeltaRefDetailed is like DiffPgDeltaRef but also returns edge-runtime stderr. @@ -136,15 +164,37 @@ func DiffPgDeltaRefDetailed(ctx context.Context, sourceRef, targetRef string, sc if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error diffing schema", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()); err != nil { return PgDeltaDiffResult{}, err } - return PgDeltaDiffResult{ - SQL: stdout.String(), - Stderr: stderr.String(), - }, nil + return parsePgDeltaDiffOutput(stdout.String(), stderr.String()) +} + +// parsePgDeltaDiffOutput turns the pg-delta diff script's stdout envelope into a +// result. The template always prints the envelope on the success path, even for +// an empty plan (`{"version":1,"files":[]}`); a truly empty stdout means no +// envelope was produced, which we surface as "no changes" (empty Files) rather +// than an error. Non-empty stdout that is not valid envelope JSON is a parse +// error carrying the edge-runtime stderr for diagnosis. +func parsePgDeltaDiffOutput(stdout, stderr string) (PgDeltaDiffResult, error) { + result := PgDeltaDiffResult{Stderr: stderr} + if len(strings.TrimSpace(stdout)) == 0 { + return result, nil + } + var envelope PgDeltaDiffOutput + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + return PgDeltaDiffResult{}, errors.Errorf("failed to parse pg-delta diff output: %w:\n%s", err, stderr) + } + result.Files = envelope.Files + return result, nil } // exportCatalogPgDelta is overridden in tests to mock catalog export. var exportCatalogPgDelta = ExportCatalogPgDelta +// diffPgDeltaRefDetailed is the seam DiffDatabase uses for the pg-delta engine. +// Tests override it to stub the real edge-runtime pipeline (which the injected +// DiffFunc differ cannot, since pg-delta bypasses differ), the same pattern as +// exportCatalogPgDelta above. +var diffPgDeltaRefDetailed = DiffPgDeltaRefDetailed + // DeclarativeExportPgDelta exports target schema as declarative file payloads // while keeping a config-based API for existing call sites. func DeclarativeExportPgDelta(ctx context.Context, source, target pgconn.Config, schema []string, formatOptions string, options ...func(*pgx.ConnConfig)) (DeclarativeOutput, error) { diff --git a/apps/cli-go/internal/db/diff/pgdelta_debug.go b/apps/cli-go/internal/db/diff/pgdelta_debug.go index b901edd5f7..1439018e80 100644 --- a/apps/cli-go/internal/db/diff/pgdelta_debug.go +++ b/apps/cli-go/internal/db/diff/pgdelta_debug.go @@ -17,9 +17,10 @@ func IsPgDeltaDebugEnabled() bool { } } -// PgDeltaDiffResult holds pg-delta diff output and edge-runtime stderr. +// PgDeltaDiffResult holds the parsed pg-delta diff envelope (one file per +// execution-aware plan unit) and the edge-runtime stderr. type PgDeltaDiffResult struct { - SQL string + Files []PgDeltaPlanFile Stderr string } @@ -31,7 +32,10 @@ type PgDeltaDebugCapture struct { // DatabaseDiff is the result of diffing a target database against a shadow baseline. type DatabaseDiff struct { - SQL string + SQL string + // Files carries the per-unit pg-delta plan files (empty for the migra engine). + // SQL is the flattened join of these, kept for callers that consume one blob. + Files []PgDeltaPlanFile Debug *PgDeltaDebugCapture } diff --git a/apps/cli-go/internal/db/diff/pgdelta_migrations.go b/apps/cli-go/internal/db/diff/pgdelta_migrations.go new file mode 100644 index 0000000000..c8f4a8b244 --- /dev/null +++ b/apps/cli-go/internal/db/diff/pgdelta_migrations.go @@ -0,0 +1,108 @@ +package diff + +import ( + "os" + "path/filepath" + "time" + + "github.com/go-errors/errors" + "github.com/spf13/afero" + "github.com/supabase/cli/internal/migration/new" + "github.com/supabase/cli/internal/utils" +) + +// maxVersionCollisionAttempts bounds the base-timestamp bump retry so a directory +// already full of same-second migrations can't spin forever. +const maxVersionCollisionAttempts = 60 + +// WrittenMigration is a migration file produced by a diff/pull, paired with the +// version to record in the remote migration history. +type WrittenMigration struct { + Path string + Version string +} + +// WritePgDeltaMigrations writes one ordered migration file per plan unit. A +// single-unit plan (the common case) keeps the exact `_.sql` filename; +// multi-unit plans append the unit name and give each file a strictly increasing +// timestamp (real time arithmetic on the base, never string increment) so their +// execution order and migration-history order stay stable. +// +// Before writing anything, the FULL set of generated filenames is collision-checked +// against the filesystem: if any target path already exists the base is advanced by +// one second and every version recomputed, so the set stays strictly ascending AND +// unique against pre-existing migrations. The base only ever moves forward — never +// backdated below the caller's wall clock, since backdating could sort a new file +// before pre-existing migrations. The resulting ≤N−1s future-dating is inherent to +// second-granularity versions and acceptable once uniqueness is enforced. +func WritePgDeltaMigrations(files []PgDeltaPlanFile, base time.Time, name string, fsys afero.Fs) (_ []WrittenMigration, err error) { + single := len(files) == 1 + buildSet := func(b time.Time) []WrittenMigration { + set := make([]WrittenMigration, len(files)) + for i, file := range files { + version := utils.GetVersionTimestamp(b.Add(time.Duration(i) * time.Second)) + fileName := name + if !single { + fileName = name + "_" + file.Name + } + set[i] = WrittenMigration{Path: new.GetMigrationPath(version, fileName), Version: version} + } + return set + } + + set := buildSet(base) + for attempt := 0; ; attempt++ { + collision := false + for _, w := range set { + exists, err := afero.Exists(fsys, w.Path) + if err != nil { + return nil, errors.Errorf("failed to check migration file: %w", err) + } + if exists { + collision = true + break + } + } + if !collision { + break + } + if attempt+1 >= maxVersionCollisionAttempts { + return nil, errors.Errorf("failed to find a unique migration version after %d attempts", maxVersionCollisionAttempts) + } + base = base.Add(time.Second) + set = buildSet(base) + } + + written := make([]WrittenMigration, 0, len(files)) + // Best-effort cleanup: if any open/write fails mid-loop, remove every file this + // invocation already wrote so a partial multi-file migration isn't left behind. + // A removal failure never masks the original error. + defer func() { + if err != nil { + for _, w := range written { + _ = fsys.Remove(w.Path) + } + } + }() + for i, file := range files { + w := set[i] + if err = utils.MkdirIfNotExistFS(fsys, filepath.Dir(w.Path)); err != nil { + return nil, err + } + // O_EXCL (not O_TRUNC): a race that created the file between the collision + // check and here must never silently overwrite an existing migration. + f, openErr := fsys.OpenFile(w.Path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if openErr != nil { + err = errors.Errorf("failed to open migration file: %w", openErr) + return nil, err + } + if _, writeErr := f.WriteString(file.SQL + "\n"); writeErr != nil { + f.Close() + err = errors.Errorf("failed to write migration file: %w", writeErr) + return nil, err + } + f.Close() + written = append(written, w) + } + return written, nil +} diff --git a/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go b/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go new file mode 100644 index 0000000000..16ace1b7ff --- /dev/null +++ b/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go @@ -0,0 +1,133 @@ +package diff + +import ( + "os" + "testing" + "time" + + "github.com/go-errors/errors" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/migration/new" +) + +// failOnNthOpenFs fails the Nth create-for-write OpenFile so a mid-loop write +// failure can be exercised deterministically. Stat/mkdir/read calls pass through. +type failOnNthOpenFs struct { + afero.Fs + failOn int + count int +} + +func (f *failOnNthOpenFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { + if flag&os.O_CREATE != 0 { + f.count++ + if f.count == f.failOn { + return nil, errors.New("simulated open failure") + } + } + return f.Fs.OpenFile(name, flag, perm) +} + +func TestWritePgDeltaMigrations(t *testing.T) { + base := time.Date(2026, 7, 17, 15, 18, 48, 0, time.UTC) + + t.Run("writes a single unit with the unchanged name", func(t *testing.T) { + fsys := afero.NewMemMapFs() + files := []PgDeltaPlanFile{ + {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "-- unit 1\n\ncreate table a ();"}, + } + written, err := WritePgDeltaMigrations(files, base, "remote_schema", fsys) + require.NoError(t, err) + require.Len(t, written, 1) + assert.Equal(t, "20260717151848", written[0].Version) + expectedPath := new.GetMigrationPath("20260717151848", "remote_schema") + assert.Equal(t, expectedPath, written[0].Path) + contents, err := afero.ReadFile(fsys, expectedPath) + require.NoError(t, err) + assert.Equal(t, "-- unit 1\n\ncreate table a ();\n", string(contents)) + }) + + t.Run("writes one ordered file per unit with strictly increasing versions", func(t *testing.T) { + fsys := afero.NewMemMapFs() + files := []PgDeltaPlanFile{ + {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "-- unit 1\n\nalter type mood add value 'ok';"}, + {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "-- unit 2\n\ninsert into t values ('ok');"}, + {Order: 3, Name: "non_transactional", TransactionMode: "none", SQL: "-- unit 3\n\ncreate index concurrently i on t (c);"}, + } + written, err := WritePgDeltaMigrations(files, base, "remote_schema", fsys) + require.NoError(t, err) + require.Len(t, written, 3) + + wantVersions := []string{"20260717151848", "20260717151849", "20260717151850"} + wantNames := []string{"remote_schema_schema_changes", "remote_schema_after_enum_values", "remote_schema_non_transactional"} + for i, w := range written { + assert.Equal(t, wantVersions[i], w.Version) + assert.Equal(t, new.GetMigrationPath(wantVersions[i], wantNames[i]), w.Path) + contents, err := afero.ReadFile(fsys, w.Path) + require.NoError(t, err) + assert.Equal(t, files[i].SQL+"\n", string(contents)) + } + // Versions are strictly increasing so history + execution order stay stable. + assert.True(t, written[0].Version < written[1].Version) + assert.True(t, written[1].Version < written[2].Version) + }) + + t.Run("creates nested parent directories for a nested migration name", func(t *testing.T) { + fsys := afero.NewMemMapFs() + files := []PgDeltaPlanFile{ + {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "create table a ();"}, + {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, + } + written, err := WritePgDeltaMigrations(files, base, "snapshots/remote", fsys) + require.NoError(t, err) + require.Len(t, written, 2) + for i, w := range written { + contents, err := afero.ReadFile(fsys, w.Path) + require.NoError(t, err) + assert.Equal(t, files[i].SQL+"\n", string(contents)) + } + }) + + t.Run("bumps the base version when a target file already exists", func(t *testing.T) { + fsys := afero.NewMemMapFs() + files := []PgDeltaPlanFile{ + {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "create table a ();"}, + {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, + } + // Pre-existing migration at the first version the base would otherwise use. + existing := new.GetMigrationPath("20260717151848", "remote_schema_schema_changes") + require.NoError(t, afero.WriteFile(fsys, existing, []byte("-- pre-existing\n"), 0644)) + + written, err := WritePgDeltaMigrations(files, base, "remote_schema", fsys) + require.NoError(t, err) + require.Len(t, written, 2) + // The whole set advances one second so it skips the colliding version and + // stays strictly ascending against the pre-existing file. + assert.Equal(t, "20260717151849", written[0].Version) + assert.Equal(t, "20260717151850", written[1].Version) + assert.True(t, written[0].Version < written[1].Version) + // The pre-existing file is untouched (never overwritten). + contents, err := afero.ReadFile(fsys, existing) + require.NoError(t, err) + assert.Equal(t, "-- pre-existing\n", string(contents)) + }) + + t.Run("removes already-written files when a later write fails", func(t *testing.T) { + fsys := &failOnNthOpenFs{Fs: afero.NewMemMapFs(), failOn: 2} + files := []PgDeltaPlanFile{ + {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "create table a ();"}, + {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, + } + written, err := WritePgDeltaMigrations(files, base, "remote_schema", fsys) + require.Error(t, err) + assert.Nil(t, written) + // The first unit's file was written then removed on the failure, so nothing + // from this invocation is left behind. + first := new.GetMigrationPath("20260717151848", "remote_schema_schema_changes") + exists, statErr := afero.Exists(fsys, first) + require.NoError(t, statErr) + assert.False(t, exists) + }) +} diff --git a/apps/cli-go/internal/db/diff/pgdelta_test.go b/apps/cli-go/internal/db/diff/pgdelta_test.go index 671414a069..ad312273c5 100644 --- a/apps/cli-go/internal/db/diff/pgdelta_test.go +++ b/apps/cli-go/internal/db/diff/pgdelta_test.go @@ -32,3 +32,40 @@ func TestContainerRef(t *testing.T) { assert.Equal(t, "/workspace/supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json", containerRef(ref)) }) } + +func TestParsePgDeltaDiffOutput(t *testing.T) { + t.Run("parses a multi-file envelope", func(t *testing.T) { + stdout := `{"version":1,"files":[` + + `{"order":1,"name":"schema_changes","transactionMode":"transactional","sql":"-- unit 1\n\nCREATE TABLE a ();"},` + + `{"order":2,"name":"after_enum_values","transactionMode":"transactional","sql":"-- unit 2\n\nINSERT INTO a VALUES (1);"}` + + `]}` + result, err := parsePgDeltaDiffOutput(stdout, "debug stderr") + assert.NoError(t, err) + assert.Equal(t, "debug stderr", result.Stderr) + assert.Len(t, result.Files, 2) + assert.Equal(t, PgDeltaPlanFile{Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "-- unit 1\n\nCREATE TABLE a ();"}, result.Files[0]) + assert.Equal(t, "after_enum_values", result.Files[1].Name) + // The flattened join keeps unit boundaries visible via header comments. + assert.Equal(t, "-- unit 1\n\nCREATE TABLE a ();\n\n-- unit 2\n\nINSERT INTO a VALUES (1);", joinPgDeltaFiles(result.Files)) + }) + + t.Run("treats an empty envelope as no changes", func(t *testing.T) { + result, err := parsePgDeltaDiffOutput(`{"version":1,"files":[]}`, "") + assert.NoError(t, err) + assert.Empty(t, result.Files) + assert.Equal(t, "", joinPgDeltaFiles(result.Files)) + }) + + t.Run("treats empty stdout as no changes", func(t *testing.T) { + result, err := parsePgDeltaDiffOutput(" \n", "") + assert.NoError(t, err) + assert.Empty(t, result.Files) + }) + + t.Run("fails on malformed json and embeds stderr", func(t *testing.T) { + _, err := parsePgDeltaDiffOutput("not json", "boom on the edge runtime") + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to parse pg-delta diff output") + assert.ErrorContains(t, err, "boom on the edge runtime") + }) +} diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta.ts b/apps/cli-go/internal/db/diff/templates/pgdelta.ts index 0fd9d00e30..2a9d2d3f2a 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta.ts @@ -1,7 +1,7 @@ import { createPlan, deserializeCatalog, - formatSqlStatements, + renderPlanFiles, } from "npm:@supabase/pg-delta@1.0.0-alpha.20"; import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase"; @@ -32,10 +32,19 @@ if (includedSchemas) { } const formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS"); -let formatOptions = undefined; -if (formatOptionsRaw) { - formatOptions = JSON.parse(formatOptionsRaw); -} +const parsedFormatOptions = formatOptionsRaw ? JSON.parse(formatOptionsRaw) : undefined; +// Format the emitted SQL by default with the same sensible settings the +// declarative export uses (`exportDeclarativeSchema` in @supabase/pg-delta: +// `{ ...DEFAULT_OPTIONS, maxWidth: 180, keywordCase: "upper", ...userOptions }`), +// so `db pull` / `db diff` produce readable migrations even when config sets no +// `[experimental.pgdelta] format_options`. The formatter fills DEFAULT_OPTIONS +// for missing keys itself, so only the two overrides are passed here. Setting +// `format_options = "null"` (parsed to `null`) is the explicit opt-out: raw, +// unformatted statements, mirroring declarative export's `formatOptions === null`. +const sqlFormatOptions = + parsedFormatOptions === null + ? undefined + : { maxWidth: 180, keywordCase: "upper", ...parsedFormatOptions }; try { const result = await createPlan( @@ -46,14 +55,32 @@ try { skipDefaultPrivilegeSubtraction: true, }, ); - let statements = result?.plan.statements ?? []; - if (formatOptions != null) { - statements = formatSqlStatements(statements, formatOptions); - } + // pg-delta >= 1.0.0-alpha.32 groups plan statements into execution-aware + // `units` with transaction boundaries. `renderPlanFiles` turns those into one + // numbered SQL file per unit (header comments included). `includeTransactions: + // false` because the CLI appliers already wrap each migration file in a single + // transaction (Go's implicit ExecBatch / the TS BEGIN/COMMIT wrap), so embedded + // BEGIN/COMMIT would nest; format options are applied per unit here instead of a + // manual `formatSqlStatements` pass. + const files = result + ? renderPlanFiles(result.plan, { + includeTransactions: false, + sqlFormatOptions, + }) + : []; + const envelope = files.map((file, index) => ({ + order: index + 1, + // The unit name is the rendered path minus its numeric prefix and `.sql` + // extension (e.g. `001_after_enum_values.sql` -> `after_enum_values`). + name: file.path.replace(/^\d+_/, "").replace(/\.sql$/, ""), + transactionMode: file.unit.transactionMode, + sql: file.sql, + })); if (Deno.env.get("PGDELTA_DEBUG")) { console.error( JSON.stringify({ - statementCount: statements.length, + statementCount: files.reduce((total, file) => total + file.unit.statements.length, 0), + fileCount: files.length, source: source ? "connected" : "null", target: target ? "connected" : "null", includedSchemas: includedSchemas ?? null, @@ -61,11 +88,13 @@ try { }), ); } - for (const sql of statements) { - console.log(`${sql};`); - } + console.log(JSON.stringify({ version: 1, files: envelope })); } catch (e) { console.error(e); + // Emit a sentinel so the CLI runner can distinguish a real script crash from a + // successful empty diff, even though the forced-exit non-zero code below is + // suppressed by the "main worker has been destroyed" handling. + console.error("PGDELTA_SCRIPT_ERROR"); // Force close event loop throw new Error(""); } diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts index 6b7d426ce1..dfecc58da1 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts @@ -12,6 +12,10 @@ const role = Deno.env.get("ROLE") ?? undefined; if (!target) { console.error("TARGET is required"); + // Emit a sentinel so the CLI runner treats this as a real script crash rather + // than a successful empty catalog, even though the forced-exit non-zero code is + // suppressed by the "main worker has been destroyed" handling. + console.error("PGDELTA_SCRIPT_ERROR"); throw new Error(""); } const { pool, close } = await createManagedPool(target, { role }); @@ -21,6 +25,10 @@ try { console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); } catch (e) { console.error(e); + // Emit a sentinel so the CLI runner can distinguish a real script crash from a + // successful empty catalog, even though the forced-exit non-zero code below is + // suppressed by the "main worker has been destroyed" handling. + console.error("PGDELTA_SCRIPT_ERROR"); // Force close event loop throw new Error(""); } finally { diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts index 4656e4690e..18820ff7b9 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts @@ -68,6 +68,10 @@ try { } } catch (e) { console.error(e); + // Emit a sentinel so the CLI runner can distinguish a real script crash from a + // successful empty export, even though the forced-exit non-zero code below is + // suppressed by the "main worker has been destroyed" handling. + console.error("PGDELTA_SCRIPT_ERROR"); // Force close event loop throw new Error(""); } diff --git a/apps/cli-go/internal/db/pgcache/cache.go b/apps/cli-go/internal/db/pgcache/cache.go index 52960c9b06..3cc1ccd4b4 100644 --- a/apps/cli-go/internal/db/pgcache/cache.go +++ b/apps/cli-go/internal/db/pgcache/cache.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "os" "path/filepath" "regexp" "sort" @@ -261,6 +262,9 @@ func exportCatalog(ctx context.Context, targetRef string, options ...func(*pgx.C } env := append([]string{"TARGET=" + preparedRef, "ROLE=postgres"}, sslEnv...) binds := []string{utils.EdgeRuntimeId + ":/root/.cache/deno:rw"} + if cwd, err := os.Getwd(); err == nil { + binds = append(binds, cwd+":/workspace") + } var stdout, stderr bytes.Buffer script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaCatalogExportTS) if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error exporting pg-delta catalog", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()); err != nil { diff --git a/apps/cli-go/internal/db/pull/pull.go b/apps/cli-go/internal/db/pull/pull.go index 07503687a7..21e0db87f0 100644 --- a/apps/cli-go/internal/db/pull/pull.go +++ b/apps/cli-go/internal/db/pull/pull.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/go-errors/errors" "github.com/jackc/pgconn" @@ -59,21 +60,26 @@ func Run(ctx context.Context, schema []string, config pgconn.Config, name string // TODO: handle managed schemas return format.WriteStructuredSchemas(ctx, &buf, fsys) } - // 2. Pull schema - timestamp := utils.GetCurrentTimestamp() - path := new.GetMigrationPath(timestamp, name) - if err := run(ctx, schema, path, conn, usePgDeltaDiff, differ, fsys, options...); err != nil { + // 2. Pull schema. pg-delta plans with transaction boundaries produce more than + // one ordered migration file; migra always produces exactly one. + base := time.Now().UTC() + written, err := run(ctx, schema, base, name, conn, usePgDeltaDiff, differ, fsys, options...) + if err != nil { return err } - if err := ensureMigrationWritten(fsys, path); err != nil { - return err + if len(written) == 0 { + return errors.New(errInSync) + } + // 3. Insert a row to `schema_migrations` for every file written. + versions := make([]string, len(written)) + for i, w := range written { + fmt.Fprintln(os.Stderr, "Schema written to "+utils.Bold(w.Path)) + versions[i] = w.Version } - // 3. Insert a row to `schema_migrations` - fmt.Fprintln(os.Stderr, "Schema written to "+utils.Bold(path)) if shouldUpdate, err := utils.NewConsole().PromptYesNo(ctx, "Update remote migration history table?", true); err != nil { return err } else if shouldUpdate { - return repair.UpdateMigrationTable(ctx, conn, []string{timestamp}, repair.Applied, false, fsys) + return repair.UpdateMigrationTable(ctx, conn, versions, repair.Applied, false, fsys) } return nil } @@ -114,8 +120,10 @@ func pullDeclarativePgDelta(ctx context.Context, schema []string, config pgconn. return nil } -func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { +func run(ctx context.Context, schema []string, base time.Time, name string, conn *pgx.Conn, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) ([]diff.WrittenMigration, error) { config := conn.Config().Config + timestamp := utils.GetVersionTimestamp(base) + path := new.GetMigrationPath(timestamp, name) // 1. Assert `supabase/migrations` and `schema_migrations` are in sync. if err := assertRemoteInSync(ctx, conn, fsys); errors.Is(err, errMissing) { // pg_dump strips ownership when restored as a non-superuser, so platform @@ -126,19 +134,27 @@ func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, useP if !usePgDeltaDiff { // Ignore schemas flag when working on the initial pull if err = dumpRemoteSchema(ctx, path, config, fsys); err != nil { - return err + return nil, err } } // For the legacy path this is a second pass that captures changes // pg_dump cannot emit (default privileges, managed schemas). For the // pg-delta path this is the only pass and produces the full schema. - err = swallowInitialInSync(diffRemoteSchema(ctx, nil, path, config, usePgDeltaDiff, differ, fsys, options...), fsys, path) - return err + written, err := diffRemoteSchema(ctx, nil, base, name, config, usePgDeltaDiff, differ, fsys, options...) + if err = swallowInitialInSync(err, fsys, path); err != nil { + return nil, err + } + // The migra initial pull seeds `path` with a pg_dump even when the follow-up + // diff is empty and swallowed above, so record that single migration. + if !usePgDeltaDiff && len(written) == 0 { + written = []diff.WrittenMigration{{Path: path, Version: timestamp}} + } + return written, nil } else if err != nil { - return err + return nil, err } // 2. Fetch remote schema changes - return diffRemoteSchema(ctx, schema, path, config, usePgDeltaDiff, differ, fsys, options...) + return diffRemoteSchema(ctx, schema, base, name, config, usePgDeltaDiff, differ, fsys, options...) } func dumpRemoteSchema(ctx context.Context, path string, config pgconn.Config, fsys afero.Fs) error { @@ -157,7 +173,7 @@ func dumpRemoteSchema(ctx context.Context, path string, config pgconn.Config, fs }) } -func diffRemoteSchema(ctx context.Context, schema []string, path string, config pgconn.Config, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { +func diffRemoteSchema(ctx context.Context, schema []string, base time.Time, name string, config pgconn.Config, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) ([]diff.WrittenMigration, error) { // Diff remote db (source) & shadow db (target) and write it as a new migration. result, err := diff.DiffDatabase(ctx, schema, config, os.Stderr, fsys, differ, usePgDeltaDiff, options...) if err != nil { @@ -166,37 +182,47 @@ func diffRemoteSchema(ctx context.Context, schema []string, path string, config // so the whole db pull workflow is self-healing, not just the dump pass. poolerConfig, ok := dump.PoolerFallbackConfig(ctx, config, err) if !ok { - return err + return nil, err } if result, err = diff.DiffDatabase(ctx, schema, poolerConfig, os.Stderr, fsys, differ, usePgDeltaDiff, options...); err != nil { - return err + return nil, err } } - output := result.SQL - if trimmed := strings.TrimSpace(output); len(trimmed) == 0 { - if usePgDeltaDiff && diff.IsPgDeltaDebugEnabled() { - if debugDir, debugErr := saveEmptyPgDeltaPullDebug(ctx, config, result.Debug, fsys, options...); debugErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to save pg-delta debug bundle: %v\n", debugErr) - } else if len(debugDir) > 0 { - return errors.Errorf("%w (debug bundle: %s)", errInSync, debugDir) + // pg-delta path: one migration file per execution-aware plan unit. + if usePgDeltaDiff { + if len(result.Files) == 0 { + if diff.IsPgDeltaDebugEnabled() { + if debugDir, debugErr := saveEmptyPgDeltaPullDebug(ctx, config, result.Debug, fsys, options...); debugErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to save pg-delta debug bundle: %v\n", debugErr) + } else if len(debugDir) > 0 { + return nil, errors.Errorf("%w (debug bundle: %s)", errInSync, debugDir) + } } + return nil, errors.New(errInSync) } - return errors.New(errInSync) + return diff.WritePgDeltaMigrations(result.Files, base, name, fsys) } + // migra path: a single migration file, appended when seeded by dumpRemoteSchema. + output := result.SQL + if trimmed := strings.TrimSpace(output); len(trimmed) == 0 { + return nil, errors.New(errInSync) + } + timestamp := utils.GetVersionTimestamp(base) + path := new.GetMigrationPath(timestamp, name) if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(path)); err != nil { - return err + return nil, err } // Append to existing migration file when we run this after dumpRemoteSchema; - // for the pg-delta path this is the only writer and creates the file fresh. + // for a non-initial pull this creates the file fresh. f, err := fsys.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) if err != nil { - return errors.Errorf("failed to open migration file: %w", err) + return nil, errors.Errorf("failed to open migration file: %w", err) } defer f.Close() if _, err := f.WriteString(output); err != nil { - return errors.Errorf("failed to write migration file: %w", err) + return nil, errors.Errorf("failed to write migration file: %w", err) } - return nil + return []diff.WrittenMigration{{Path: path, Version: timestamp}}, nil } func assertRemoteInSync(ctx context.Context, conn *pgx.Conn, fsys afero.Fs) error { diff --git a/apps/cli-go/internal/db/pull/pull_test.go b/apps/cli-go/internal/db/pull/pull_test.go index e9fbae9198..ec3e784f83 100644 --- a/apps/cli-go/internal/db/pull/pull_test.go +++ b/apps/cli-go/internal/db/pull/pull_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/h2non/gock" "github.com/jackc/pgconn" @@ -14,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/db/diff" + "github.com/supabase/cli/internal/migration/new" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/utils" @@ -75,11 +77,13 @@ func TestPullSchema(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") // Run test - err := run(context.Background(), nil, "0_test.sql", conn.MockClient(t), false, diff.DiffSchemaMigra, fsys) + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + path := new.GetMigrationPath(utils.GetVersionTimestamp(base), "test") + _, err := run(context.Background(), nil, base, "test", conn.MockClient(t), false, diff.DiffSchemaMigra, fsys) // Check error assert.ErrorIs(t, err, errNetwork) assert.Empty(t, apitest.ListUnmatchedRequests()) - contents, err := afero.ReadFile(fsys, "0_test.sql") + contents, err := afero.ReadFile(fsys, path) assert.NoError(t, err) assert.Equal(t, []byte("test"), contents) }) @@ -102,12 +106,14 @@ func TestPullSchema(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") // Run test with usePgDeltaDiff=true - err := run(context.Background(), nil, "0_test.sql", conn.MockClient(t), true, diff.DiffPgDelta, fsys) + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + path := new.GetMigrationPath(utils.GetVersionTimestamp(base), "test") + _, err := run(context.Background(), nil, base, "test", conn.MockClient(t), true, diff.DiffPgDelta, fsys) // Failure must come from shadow-creation image inspect (proving we // reached the diff step), not from pg_dump. assert.ErrorIs(t, err, errNetwork) assert.Empty(t, apitest.ListUnmatchedRequests()) - exists, err := afero.Exists(fsys, "0_test.sql") + exists, err := afero.Exists(fsys, path) assert.NoError(t, err) assert.False(t, exists, "pg_dump should be skipped for pg-delta diff engine") }) @@ -129,7 +135,8 @@ func TestPullSchema(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 1", []any{"0"}) // Run test - err := run(context.Background(), []string{"public"}, "", conn.MockClient(t), false, diff.DiffSchemaMigra, fsys) + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + _, err := run(context.Background(), []string{"public"}, base, "test", conn.MockClient(t), false, diff.DiffSchemaMigra, fsys) // Check error assert.ErrorContains(t, err, "network error") assert.Empty(t, apitest.ListUnmatchedRequests()) diff --git a/apps/cli-go/internal/db/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go index 7d841f4ba3..765153d6d7 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -92,6 +92,38 @@ func toLogMessage(version string) string { return "..." } +// RecreateLocalDatabase is the container-lifecycle half of a local `db reset`, +// exposed for the native-TypeScript `db reset --local` seam (cmd db __db-bootstrap). +// It performs the PG14/PG15 branch — recreate the db container/volume, init schema, +// migrate + seed, and restart the satellite containers — WITHOUT the leading +// "Resetting local database…" line, which the TS caller prints itself. Mirrors +// resetDatabase (above) minus that message. +func RecreateLocalDatabase(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { + if utils.Config.Db.MajorVersion <= 14 { + return resetDatabase14(ctx, version, fsys, options...) + } + return resetDatabase15(ctx, version, fsys, options...) +} + +// AwaitStorageReady mirrors the storage-health gate that local `db reset` runs +// before seeding buckets (Run, above): if the storage container exists but is not +// healthy, wait up to 30s for it. It reports whether the storage container exists +// so the native-TypeScript caller knows whether to run the (already-ported) bucket +// seeding. Any inspect error is treated as "storage not running" → false, matching +// Go's `err == nil` gate, which silently skips buckets on any inspect failure. +func AwaitStorageReady(ctx context.Context) (bool, error) { + resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId) + if err != nil { + return false, 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 false, err + } + } + return true, nil +} + func resetDatabase14(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { if err := recreateDatabase(ctx, options...); err != nil { return err diff --git a/apps/cli-go/internal/functions/download/download.go b/apps/cli-go/internal/functions/download/download.go index 74d0c13e4d..3e3737ba1e 100644 --- a/apps/cli-go/internal/functions/download/download.go +++ b/apps/cli-go/internal/functions/download/download.go @@ -23,6 +23,7 @@ import ( "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" @@ -35,6 +36,15 @@ var ( 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. { @@ -160,6 +170,22 @@ func downloadAll(ctx context.Context, projectRef string, fsys afero.Fs, download 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 } @@ -289,6 +315,10 @@ 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"` } @@ -401,17 +431,196 @@ func saveFile(file *multipart.FileHeader, entrypointPath, funcDir string, fsys a relPath, err := filepath.Rel(filepath.FromSlash(entrypointPath), filepath.FromSlash(partPath)) if err != nil { - // Continue extracting without entrypoint - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), err) + // 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 := afero.WriteReader(fsys, dstPath, part); err != nil { + 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 } diff --git a/apps/cli-go/internal/functions/download/download_test.go b/apps/cli-go/internal/functions/download/download_test.go index 6ec601d928..355b0ec39c 100644 --- a/apps/cli-go/internal/functions/download/download_test.go +++ b/apps/cli-go/internal/functions/download/download_test.go @@ -308,6 +308,93 @@ func TestRunDockerUnbundle(t *testing.T) { }) } +// 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.FunctionResponse{{ + 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) @@ -453,6 +540,216 @@ func TestRunServerSideUnbundle(t *testing.T) { 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) { diff --git a/apps/cli-go/internal/sso/create/create.go b/apps/cli-go/internal/sso/create/create.go index acf3eb14e5..3716e5ee73 100644 --- a/apps/cli-go/internal/sso/create/create.go +++ b/apps/cli-go/internal/sso/create/create.go @@ -52,15 +52,7 @@ func Run(ctx context.Context, params RunParams) error { } if params.AttributeMapping != "" { - body.AttributeMapping = &struct { - Keys map[string]struct { - Array *bool "json:\"array,omitempty\"" - Default *any "json:\"default,omitempty\"" - Name *string "json:\"name,omitempty\"" - Names *[]string "json:\"names,omitempty\"" - } "json:\"keys\"" - }{} - if err := saml.ReadAttributeMappingFile(Fs, params.AttributeMapping, body.AttributeMapping); err != nil { + if err := saml.ReadAttributeMappingFile(Fs, params.AttributeMapping, &body.AttributeMapping); err != nil { return err } } diff --git a/apps/cli-go/internal/sso/internal/saml/files_test.go b/apps/cli-go/internal/sso/internal/saml/files_test.go index cc5c59b854..cd512a599e 100644 --- a/apps/cli-go/internal/sso/internal/saml/files_test.go +++ b/apps/cli-go/internal/sso/internal/saml/files_test.go @@ -22,7 +22,7 @@ func TestReadAttributeMappingFile(t *testing.T) { fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/not-valid-json", []byte("not-valid-JSON"), 0755)) var body api.CreateProviderBody - err := ReadAttributeMappingFile(fs, "/not-valid-json", body.AttributeMapping) + err := ReadAttributeMappingFile(fs, "/not-valid-json", &body.AttributeMapping) assert.ErrorContains(t, err, "failed to parse attribute mapping") }) @@ -30,24 +30,16 @@ func TestReadAttributeMappingFile(t *testing.T) { fs := afero.NewMemMapFs() data := `{"keys":{"abc":{"names":["x","y","z"],"default":2,"name":"k"}}}` require.NoError(t, afero.WriteFile(fs, "/valid-json", []byte(data), 0755)) - body := api.CreateProviderBody{ - AttributeMapping: &struct { - Keys map[string]struct { - Array *bool "json:\"array,omitempty\"" - Default *any "json:\"default,omitempty\"" - Name *string "json:\"name,omitempty\"" - Names *[]string "json:\"names,omitempty\"" - } "json:\"keys\"" - }{}, - } - err := ReadAttributeMappingFile(fs, "/valid-json", body.AttributeMapping) + var body api.CreateProviderBody + err := ReadAttributeMappingFile(fs, "/valid-json", &body.AttributeMapping) assert.NoError(t, err) + require.NotNil(t, body.AttributeMapping) assert.Len(t, body.AttributeMapping.Keys, 1) value := body.AttributeMapping.Keys["abc"] assert.Equal(t, cast.Ptr("k"), value.Name) assert.Equal(t, &[]string{"x", "y", "z"}, value.Names) assert.NotNil(t, value.Default) - assert.Equal(t, float64(2), *value.Default) + assert.Equal(t, float64(2), value.Default) }) } diff --git a/apps/cli-go/internal/sso/update/update.go b/apps/cli-go/internal/sso/update/update.go index 4e54b8961e..6a4dc9b294 100644 --- a/apps/cli-go/internal/sso/update/update.go +++ b/apps/cli-go/internal/sso/update/update.go @@ -74,15 +74,7 @@ func Run(ctx context.Context, params RunParams) error { } if params.AttributeMapping != "" { - body.AttributeMapping = &struct { - Keys map[string]struct { - Array *bool "json:\"array,omitempty\"" - Default *any "json:\"default,omitempty\"" - Name *string "json:\"name,omitempty\"" - Names *[]string "json:\"names,omitempty\"" - } "json:\"keys\"" - }{} - if err := saml.ReadAttributeMappingFile(Fs, params.AttributeMapping, body.AttributeMapping); err != nil { + if err := saml.ReadAttributeMappingFile(Fs, params.AttributeMapping, &body.AttributeMapping); err != nil { return err } } diff --git a/apps/cli-go/internal/start/start.go b/apps/cli-go/internal/start/start.go index 23aa631e3b..6264016e79 100644 --- a/apps/cli-go/internal/start/start.go +++ b/apps/cli-go/internal/start/start.go @@ -128,6 +128,13 @@ type vectorConfig struct { DbId string } +func shouldMountRootDockerSocket(host string) bool { + return strings.HasSuffix(host, "/.docker/run/docker.sock") || + strings.HasSuffix(host, "/.docker/desktop/docker.sock") || + (strings.Contains(host, "/.colima/") && strings.HasSuffix(host, "/docker.sock")) || + strings.HasSuffix(host, "/.colima/docker.sock") +} + var ( //go:embed templates/vector.yaml vectorConfigEmbed string @@ -424,8 +431,7 @@ EOF case "unix": if dindHost, err = client.ParseHostURL(client.DefaultDockerHost); err != nil { return errors.Errorf("failed to parse default host: %w", err) - } else if strings.HasSuffix(parsed.Host, "/.docker/run/docker.sock") || - strings.HasSuffix(parsed.Host, "/.docker/desktop/docker.sock") { + } else if shouldMountRootDockerSocket(parsed.Host) { // Docker will not mount rootless socket directly; // instead, specify root socket to have it handled under the hood binds = append(binds, fmt.Sprintf("%[1]s:%[1]s:ro", dindHost.Host)) diff --git a/apps/cli-go/internal/start/start_test.go b/apps/cli-go/internal/start/start_test.go index 2977c5ed79..a69f7bd8bc 100644 --- a/apps/cli-go/internal/start/start_test.go +++ b/apps/cli-go/internal/start/start_test.go @@ -162,6 +162,20 @@ func TestStartCommand(t *testing.T) { }) } +func TestShouldMountRootDockerSocket(t *testing.T) { + t.Run("returns true for Docker Desktop and Colima sockets", func(t *testing.T) { + assert.True(t, shouldMountRootDockerSocket("/Users/test/.docker/run/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.docker/desktop/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.colima/default/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.colima/local/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.colima/docker.sock")) + }) + + t.Run("returns false for directly mountable sockets", func(t *testing.T) { + assert.False(t, shouldMountRootDockerSocket("/Users/test/.orbstack/run/docker.sock")) + }) +} + func TestDatabaseStart(t *testing.T) { t.Run("starts database locally", func(t *testing.T) { // Setup in-memory fs diff --git a/apps/cli-go/internal/telemetry/client.go b/apps/cli-go/internal/telemetry/client.go index 34b529a3b3..717e4af181 100644 --- a/apps/cli-go/internal/telemetry/client.go +++ b/apps/cli-go/internal/telemetry/client.go @@ -1,11 +1,14 @@ 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 { @@ -24,6 +27,8 @@ type queueClient interface { type constructor func(apiKey string, config posthog.Config) (queueClient, error) +const shutdownTimeout = 2 * time.Second + type Client struct { client queueClient baseProperties posthog.Properties @@ -38,7 +43,15 @@ func NewClient(apiKey string, endpoint string, baseProperties map[string]any, fa return posthog.NewWithConfig(apiKey, config) } } - config := posthog.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 } diff --git a/apps/cli-go/internal/telemetry/client_test.go b/apps/cli-go/internal/telemetry/client_test.go index 6ee8ccb27a..bcc8db7d24 100644 --- a/apps/cli-go/internal/telemetry/client_test.go +++ b/apps/cli-go/internal/telemetry/client_test.go @@ -1,10 +1,14 @@ 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" @@ -40,6 +44,8 @@ func TestNewClient(t *testing.T) { 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) { @@ -53,6 +59,37 @@ func TestNewClient(t *testing.T) { 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() diff --git a/apps/cli-go/internal/utils/edgeruntime.go b/apps/cli-go/internal/utils/edgeruntime.go index c81169ea6f..8e54afa628 100644 --- a/apps/cli-go/internal/utils/edgeruntime.go +++ b/apps/cli-go/internal/utils/edgeruntime.go @@ -13,6 +13,18 @@ import ( "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 { @@ -119,6 +131,14 @@ func RunEdgeRuntimeScript(ctx context.Context, env []string, script string, bind ); 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 } diff --git a/apps/cli-go/internal/utils/edgeruntime_test.go b/apps/cli-go/internal/utils/edgeruntime_test.go index 471a518ee6..2497630255 100644 --- a/apps/cli-go/internal/utils/edgeruntime_test.go +++ b/apps/cli-go/internal/utils/edgeruntime_test.go @@ -1,14 +1,97 @@ 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( diff --git a/apps/cli-go/internal/utils/misc.go b/apps/cli-go/internal/utils/misc.go index 8e9870531b..3faf385966 100644 --- a/apps/cli-go/internal/utils/misc.go +++ b/apps/cli-go/internal/utils/misc.go @@ -129,7 +129,14 @@ func IsPgDeltaEnabled() bool { func GetCurrentTimestamp() string { // Magic number: https://stackoverflow.com/q/45160822. - return time.Now().UTC().Format(layoutVersion) + 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) { diff --git a/apps/cli-go/pkg/api/client.gen.go b/apps/cli-go/pkg/api/client.gen.go index 7ff5b0a26c..b8684b994f 100644 --- a/apps/cli-go/pkg/api/client.gen.go +++ b/apps/cli-go/pkg/api/client.gen.go @@ -1,6 +1,6 @@ // Package api provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. package api import ( @@ -221,6 +221,9 @@ type ClientInterface interface { // V1GetProjectLogsAll request V1GetProjectLogsAll(ctx context.Context, ref string, params *V1GetProjectLogsAllParams, 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) @@ -1282,6 +1285,18 @@ func (c *Client) V1GetProjectLogsAll(ctx context.Context, ref string, params *V1 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 { @@ -3462,7 +3477,7 @@ func NewV1DeleteABranchRequest(server string, branchIdOrRef string, params *V1De var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "branch_id_or_ref", runtime.ParamLocationPath, branchIdOrRef) + 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 } @@ -3483,28 +3498,33 @@ func NewV1DeleteABranchRequest(server string, branchIdOrRef string, params *V1De } 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.StyleParamWithLocation("form", true, "force", runtime.ParamLocationQuery, *params.Force); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "force", *params.Force, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -3518,7 +3538,7 @@ func NewV1GetABranchConfigRequest(server string, branchIdOrRef string) (*http.Re var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "branch_id_or_ref", runtime.ParamLocationPath, branchIdOrRef) + 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 } @@ -3538,7 +3558,7 @@ func NewV1GetABranchConfigRequest(server string, branchIdOrRef string) (*http.Re return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -3563,7 +3583,7 @@ func NewV1UpdateABranchConfigRequestWithBody(server string, branchIdOrRef string var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "branch_id_or_ref", runtime.ParamLocationPath, branchIdOrRef) + 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 } @@ -3583,7 +3603,7 @@ func NewV1UpdateABranchConfigRequestWithBody(server string, branchIdOrRef string return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -3599,7 +3619,7 @@ func NewV1DiffABranchRequest(server string, branchIdOrRef string, params *V1Diff var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "branch_id_or_ref", runtime.ParamLocationPath, branchIdOrRef) + 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 } @@ -3620,19 +3640,21 @@ func NewV1DiffABranchRequest(server string, branchIdOrRef string, params *V1Diff } 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.StyleParamWithLocation("form", true, "included_schemas", runtime.ParamLocationQuery, *params.IncludedSchemas); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -3640,24 +3662,23 @@ func NewV1DiffABranchRequest(server string, branchIdOrRef string, params *V1Diff if params.Pgdelta != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pgdelta", runtime.ParamLocationQuery, *params.Pgdelta); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pgdelta", *params.Pgdelta, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -3682,7 +3703,7 @@ func NewV1MergeABranchRequestWithBody(server string, branchIdOrRef string, conte var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "branch_id_or_ref", runtime.ParamLocationPath, branchIdOrRef) + 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 } @@ -3702,7 +3723,7 @@ func NewV1MergeABranchRequestWithBody(server string, branchIdOrRef string, conte return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -3729,7 +3750,7 @@ func NewV1PushABranchRequestWithBody(server string, branchIdOrRef string, conten var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "branch_id_or_ref", runtime.ParamLocationPath, branchIdOrRef) + 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 } @@ -3749,7 +3770,7 @@ func NewV1PushABranchRequestWithBody(server string, branchIdOrRef string, conten return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -3776,7 +3797,7 @@ func NewV1ResetABranchRequestWithBody(server string, branchIdOrRef string, conte var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "branch_id_or_ref", runtime.ParamLocationPath, branchIdOrRef) + 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 } @@ -3796,7 +3817,7 @@ func NewV1ResetABranchRequestWithBody(server string, branchIdOrRef string, conte return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -3812,7 +3833,7 @@ func NewV1RestoreABranchRequest(server string, branchIdOrRef string) (*http.Requ var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "branch_id_or_ref", runtime.ParamLocationPath, branchIdOrRef) + 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 } @@ -3832,7 +3853,7 @@ func NewV1RestoreABranchRequest(server string, branchIdOrRef string) (*http.Requ return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -3860,55 +3881,45 @@ func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*h } 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.StyleParamWithLocation("form", true, "client_id", runtime.ParamLocationQuery, params.ClientId); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "response_type", runtime.ParamLocationQuery, params.ResponseType); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "redirect_uri", runtime.ParamLocationQuery, params.RedirectUri); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } if params.Scope != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "scope", runtime.ParamLocationQuery, *params.Scope); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -3916,15 +3927,11 @@ func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*h if params.State != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "state", runtime.ParamLocationQuery, *params.State); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -3932,15 +3939,11 @@ func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*h if params.ResponseMode != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "response_mode", runtime.ParamLocationQuery, *params.ResponseMode); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -3948,15 +3951,11 @@ func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*h if params.CodeChallenge != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "code_challenge", runtime.ParamLocationQuery, *params.CodeChallenge); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -3964,15 +3963,11 @@ func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*h if params.CodeChallengeMethod != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "code_challenge_method", runtime.ParamLocationQuery, *params.CodeChallengeMethod); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -3980,15 +3975,11 @@ func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*h if params.OrganizationSlug != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "organization_slug", runtime.ParamLocationQuery, *params.OrganizationSlug); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -3996,15 +3987,11 @@ func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*h if params.TargetFlow != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "target_flow", runtime.ParamLocationQuery, *params.TargetFlow); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4012,24 +3999,23 @@ func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*h if params.Resource != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "resource", runtime.ParamLocationQuery, *params.Resource); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4057,67 +4043,53 @@ func NewV1OauthAuthorizeProjectClaimRequest(server string, params *V1OauthAuthor } 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.StyleParamWithLocation("form", true, "project_ref", runtime.ParamLocationQuery, params.ProjectRef); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "client_id", runtime.ParamLocationQuery, params.ClientId); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "response_type", runtime.ParamLocationQuery, params.ResponseType); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "redirect_uri", runtime.ParamLocationQuery, params.RedirectUri); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } if params.State != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "state", runtime.ParamLocationQuery, *params.State); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4125,15 +4097,11 @@ func NewV1OauthAuthorizeProjectClaimRequest(server string, params *V1OauthAuthor if params.ResponseMode != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "response_mode", runtime.ParamLocationQuery, *params.ResponseMode); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4141,15 +4109,11 @@ func NewV1OauthAuthorizeProjectClaimRequest(server string, params *V1OauthAuthor if params.CodeChallenge != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "code_challenge", runtime.ParamLocationQuery, *params.CodeChallenge); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4157,24 +4121,23 @@ func NewV1OauthAuthorizeProjectClaimRequest(server string, params *V1OauthAuthor if params.CodeChallengeMethod != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "code_challenge_method", runtime.ParamLocationQuery, *params.CodeChallengeMethod); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4212,7 +4175,7 @@ func NewV1RevokeTokenRequestWithBody(server string, contentType string, body io. return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -4252,7 +4215,7 @@ func NewV1ExchangeOauthTokenRequestWithBody(server string, contentType string, b return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -4281,7 +4244,7 @@ func NewV1ListAllOrganizationsRequest(server string) (*http.Request, error) { return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4319,7 +4282,7 @@ func NewV1CreateAnOrganizationRequestWithBody(server string, contentType string, return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -4335,7 +4298,7 @@ func NewV1GetAnOrganizationRequest(server string, slug string) (*http.Request, e var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "slug", runtime.ParamLocationPath, slug) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4355,7 +4318,7 @@ func NewV1GetAnOrganizationRequest(server string, slug string) (*http.Request, e return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4369,7 +4332,7 @@ func NewV1GetOrganizationEntitlementsRequest(server string, slug string) (*http. var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "slug", runtime.ParamLocationPath, slug) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4389,7 +4352,7 @@ func NewV1GetOrganizationEntitlementsRequest(server string, slug string) (*http. return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4403,7 +4366,7 @@ func NewV1ListOrganizationMembersRequest(server string, slug string) (*http.Requ var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "slug", runtime.ParamLocationPath, slug) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4423,7 +4386,7 @@ func NewV1ListOrganizationMembersRequest(server string, slug string) (*http.Requ return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4437,14 +4400,14 @@ func NewV1GetOrganizationProjectClaimRequest(server string, slug string, token s var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "slug", runtime.ParamLocationPath, slug) + 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.StyleParamWithLocation("simple", false, "token", runtime.ParamLocationPath, token) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "token", token, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4464,7 +4427,7 @@ func NewV1GetOrganizationProjectClaimRequest(server string, slug string, token s return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4478,14 +4441,14 @@ func NewV1ClaimProjectForOrganizationRequest(server string, slug string, token s var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "slug", runtime.ParamLocationPath, slug) + 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.StyleParamWithLocation("simple", false, "token", runtime.ParamLocationPath, token) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "token", token, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4505,7 +4468,7 @@ func NewV1ClaimProjectForOrganizationRequest(server string, slug string, token s return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -4519,7 +4482,7 @@ func NewV1GetAllProjectsForOrganizationRequest(server string, slug string, param var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "slug", runtime.ParamLocationPath, slug) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4540,19 +4503,21 @@ func NewV1GetAllProjectsForOrganizationRequest(server string, slug string, param } 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.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4560,15 +4525,11 @@ func NewV1GetAllProjectsForOrganizationRequest(server string, slug string, param if params.Limit != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4576,15 +4537,11 @@ func NewV1GetAllProjectsForOrganizationRequest(server string, slug string, param if params.Search != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "search", runtime.ParamLocationQuery, *params.Search); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4592,15 +4549,11 @@ func NewV1GetAllProjectsForOrganizationRequest(server string, slug string, param if params.Sort != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "sort", runtime.ParamLocationQuery, *params.Sort); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4608,24 +4561,23 @@ func NewV1GetAllProjectsForOrganizationRequest(server string, slug string, param if params.Statuses != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "statuses", runtime.ParamLocationQuery, *params.Statuses); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4652,7 +4604,7 @@ func NewV1GetProfileRequest(server string) (*http.Request, error) { return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4679,7 +4631,7 @@ func NewV1ListAllProjectsRequest(server string) (*http.Request, error) { return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4717,7 +4669,7 @@ func NewV1CreateAProjectRequestWithBody(server string, contentType string, body return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -4747,31 +4699,29 @@ func NewV1GetAvailableRegionsRequest(server string, params *V1GetAvailableRegion } 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.StyleParamWithLocation("form", true, "organization_slug", runtime.ParamLocationQuery, params.OrganizationSlug); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } if params.Continent != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "continent", runtime.ParamLocationQuery, *params.Continent); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4779,24 +4729,23 @@ func NewV1GetAvailableRegionsRequest(server string, params *V1GetAvailableRegion if params.DesiredInstanceSize != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "desired_instance_size", runtime.ParamLocationQuery, *params.DesiredInstanceSize); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4810,7 +4759,7 @@ func NewV1DeleteAProjectRequest(server string, ref string) (*http.Request, error var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4830,7 +4779,7 @@ func NewV1DeleteAProjectRequest(server string, ref string) (*http.Request, error return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -4844,7 +4793,7 @@ func NewV1GetProjectRequest(server string, ref string) (*http.Request, error) { var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4864,7 +4813,7 @@ func NewV1GetProjectRequest(server string, ref string) (*http.Request, error) { return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4889,7 +4838,7 @@ func NewV1UpdateAProjectRequestWithBody(server string, ref string, contentType s var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4909,7 +4858,7 @@ func NewV1UpdateAProjectRequestWithBody(server string, ref string, contentType s return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -4925,7 +4874,7 @@ func NewV1ListActionRunsRequest(server string, ref string, params *V1ListActionR var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -4946,19 +4895,21 @@ func NewV1ListActionRunsRequest(server string, ref string, params *V1ListActionR } 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.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -4966,24 +4917,23 @@ func NewV1ListActionRunsRequest(server string, ref string, params *V1ListActionR if params.Limit != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -4997,7 +4947,7 @@ func NewV1CountActionRunsRequest(server string, ref string) (*http.Request, erro var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5017,7 +4967,7 @@ func NewV1CountActionRunsRequest(server string, ref string) (*http.Request, erro return nil, err } - req, err := http.NewRequest("HEAD", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodHead, queryURL.String(), nil) if err != nil { return nil, err } @@ -5031,14 +4981,14 @@ func NewV1GetActionRunRequest(server string, ref string, runId string) (*http.Re var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "run_id", runtime.ParamLocationPath, runId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "run_id", runId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5058,7 +5008,7 @@ func NewV1GetActionRunRequest(server string, ref string, runId string) (*http.Re return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5072,14 +5022,14 @@ func NewV1GetActionRunLogsRequest(server string, ref string, runId string) (*htt var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "run_id", runtime.ParamLocationPath, runId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "run_id", runId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5099,7 +5049,7 @@ func NewV1GetActionRunLogsRequest(server string, ref string, runId string) (*htt return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5124,14 +5074,14 @@ func NewV1UpdateActionRunStatusRequestWithBody(server string, ref string, runId var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "run_id", runtime.ParamLocationPath, runId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "run_id", runId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5151,7 +5101,7 @@ func NewV1UpdateActionRunStatusRequestWithBody(server string, ref string, runId return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -5167,7 +5117,7 @@ func NewV1GetPerformanceAdvisorsRequest(server string, ref string) (*http.Reques var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5187,7 +5137,7 @@ func NewV1GetPerformanceAdvisorsRequest(server string, ref string) (*http.Reques return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5201,7 +5151,7 @@ func NewV1GetSecurityAdvisorsRequest(server string, ref string, params *V1GetSec var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5222,28 +5172,33 @@ func NewV1GetSecurityAdvisorsRequest(server string, ref string, params *V1GetSec } 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.StyleParamWithLocation("form", true, "lint_type", runtime.ParamLocationQuery, *params.LintType); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5257,7 +5212,7 @@ func NewV1GetProjectFunctionCombinedStatsRequest(server string, ref string, para var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5278,36 +5233,37 @@ func NewV1GetProjectFunctionCombinedStatsRequest(server string, ref string, para } 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.StyleParamWithLocation("form", true, "interval", runtime.ParamLocationQuery, params.Interval); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "function_id", runtime.ParamLocationQuery, params.FunctionId); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5321,7 +5277,7 @@ func NewV1GetProjectLogsRequest(server string, ref string, params *V1GetProjectL var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5342,19 +5298,21 @@ func NewV1GetProjectLogsRequest(server string, ref string, params *V1GetProjectL } 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.StyleParamWithLocation("form", true, "sql", runtime.ParamLocationQuery, *params.Sql); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -5362,15 +5320,11 @@ func NewV1GetProjectLogsRequest(server string, ref string, params *V1GetProjectL if params.IsoTimestampStart != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "iso_timestamp_start", runtime.ParamLocationQuery, *params.IsoTimestampStart); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -5378,24 +5332,23 @@ func NewV1GetProjectLogsRequest(server string, ref string, params *V1GetProjectL if params.IsoTimestampEnd != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "iso_timestamp_end", runtime.ParamLocationQuery, *params.IsoTimestampEnd); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5409,7 +5362,7 @@ func NewV1GetProjectLogsAllRequest(server string, ref string, params *V1GetProje var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5430,19 +5383,21 @@ func NewV1GetProjectLogsAllRequest(server string, ref string, params *V1GetProje } 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.StyleParamWithLocation("form", true, "sql", runtime.ParamLocationQuery, *params.Sql); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -5450,15 +5405,11 @@ func NewV1GetProjectLogsAllRequest(server string, ref string, params *V1GetProje if params.IsoTimestampStart != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "iso_timestamp_start", runtime.ParamLocationQuery, *params.IsoTimestampStart); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -5466,24 +5417,57 @@ func NewV1GetProjectLogsAllRequest(server string, ref string, params *V1GetProje if params.IsoTimestampEnd != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "iso_timestamp_end", runtime.ParamLocationQuery, *params.IsoTimestampEnd); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + 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 +} + +// 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("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5497,7 +5481,7 @@ func NewV1GetProjectUsageApiCountRequest(server string, ref string, params *V1Ge var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5518,28 +5502,33 @@ func NewV1GetProjectUsageApiCountRequest(server string, ref string, params *V1Ge } 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.StyleParamWithLocation("form", true, "interval", runtime.ParamLocationQuery, *params.Interval); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5553,7 +5542,7 @@ func NewV1GetProjectUsageRequestCountRequest(server string, ref string) (*http.R var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5573,7 +5562,7 @@ func NewV1GetProjectUsageRequestCountRequest(server string, ref string) (*http.R return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5587,7 +5576,7 @@ func NewV1GetProjectApiKeysRequest(server string, ref string, params *V1GetProje var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5608,28 +5597,33 @@ func NewV1GetProjectApiKeysRequest(server string, ref string, params *V1GetProje } 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.StyleParamWithLocation("form", true, "reveal", runtime.ParamLocationQuery, *params.Reveal); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5654,7 +5648,7 @@ func NewV1CreateProjectApiKeyRequestWithBody(server string, ref string, params * var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5675,28 +5669,33 @@ func NewV1CreateProjectApiKeyRequestWithBody(server string, ref string, params * } 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.StyleParamWithLocation("form", true, "reveal", runtime.ParamLocationQuery, *params.Reveal); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -5712,7 +5711,7 @@ func NewV1GetProjectLegacyApiKeysRequest(server string, ref string) (*http.Reque var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5732,7 +5731,7 @@ func NewV1GetProjectLegacyApiKeysRequest(server string, ref string) (*http.Reque return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5746,7 +5745,7 @@ func NewV1UpdateProjectLegacyApiKeysRequest(server string, ref string, params *V var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -5767,24 +5766,29 @@ func NewV1UpdateProjectLegacyApiKeysRequest(server string, ref string, params *V } 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.StyleParamWithLocation("form", true, "enabled", runtime.ParamLocationQuery, params.Enabled); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "enabled", params.Enabled, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("PUT", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPut, queryURL.String(), nil) if err != nil { return nil, err } @@ -5798,14 +5802,14 @@ func NewV1DeleteProjectApiKeyRequest(server string, ref string, id openapi_types var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -5826,19 +5830,21 @@ func NewV1DeleteProjectApiKeyRequest(server string, ref string, id openapi_types } 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.StyleParamWithLocation("form", true, "reveal", runtime.ParamLocationQuery, *params.Reveal); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -5846,15 +5852,11 @@ func NewV1DeleteProjectApiKeyRequest(server string, ref string, id openapi_types if params.WasCompromised != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "was_compromised", runtime.ParamLocationQuery, *params.WasCompromised); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "was_compromised", *params.WasCompromised, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -5862,24 +5864,23 @@ func NewV1DeleteProjectApiKeyRequest(server string, ref string, id openapi_types if params.Reason != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "reason", runtime.ParamLocationQuery, *params.Reason); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -5893,14 +5894,14 @@ func NewV1GetProjectApiKeyRequest(server string, ref string, id openapi_types.UU var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -5921,28 +5922,33 @@ func NewV1GetProjectApiKeyRequest(server string, ref string, id openapi_types.UU } 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.StyleParamWithLocation("form", true, "reveal", runtime.ParamLocationQuery, *params.Reveal); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -5967,14 +5973,14 @@ func NewV1UpdateProjectApiKeyRequestWithBody(server string, ref string, id opena var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -5995,28 +6001,33 @@ func NewV1UpdateProjectApiKeyRequestWithBody(server string, ref string, id opena } 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.StyleParamWithLocation("form", true, "reveal", runtime.ParamLocationQuery, *params.Reveal); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -6032,7 +6043,7 @@ func NewV1ListProjectAddonsRequest(server string, ref string) (*http.Request, er var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6052,7 +6063,7 @@ func NewV1ListProjectAddonsRequest(server string, ref string) (*http.Request, er return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -6077,7 +6088,7 @@ func NewV1ApplyProjectAddonRequestWithBody(server string, ref string, contentTyp var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6097,7 +6108,7 @@ func NewV1ApplyProjectAddonRequestWithBody(server string, ref string, contentTyp return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -6115,14 +6126,14 @@ func NewV1RemoveProjectAddonRequest(server string, ref string, addonVariant stru var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "addon_variant", runtime.ParamLocationPath, addonVariant) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "addon_variant", addonVariant, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "", Format: ""}) if err != nil { return nil, err } @@ -6142,7 +6153,7 @@ func NewV1RemoveProjectAddonRequest(server string, ref string, addonVariant stru return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -6156,7 +6167,7 @@ func NewV1DisablePreviewBranchingRequest(server string, ref string) (*http.Reque var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6176,7 +6187,7 @@ func NewV1DisablePreviewBranchingRequest(server string, ref string) (*http.Reque return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -6190,7 +6201,7 @@ func NewV1ListAllBranchesRequest(server string, ref string) (*http.Request, erro var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6210,7 +6221,7 @@ func NewV1ListAllBranchesRequest(server string, ref string) (*http.Request, erro return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -6235,7 +6246,7 @@ func NewV1CreateABranchRequestWithBody(server string, ref string, contentType st var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6255,7 +6266,7 @@ func NewV1CreateABranchRequestWithBody(server string, ref string, contentType st return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -6271,14 +6282,14 @@ func NewV1GetABranchRequest(server string, ref string, name string) (*http.Reque var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6298,7 +6309,7 @@ func NewV1GetABranchRequest(server string, ref string, name string) (*http.Reque return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -6312,7 +6323,7 @@ func NewV1DeleteProjectClaimTokenRequest(server string, ref string) (*http.Reque var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6332,7 +6343,7 @@ func NewV1DeleteProjectClaimTokenRequest(server string, ref string) (*http.Reque return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -6346,7 +6357,7 @@ func NewV1GetProjectClaimTokenRequest(server string, ref string) (*http.Request, var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6366,7 +6377,7 @@ func NewV1GetProjectClaimTokenRequest(server string, ref string) (*http.Request, return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -6380,7 +6391,7 @@ func NewV1CreateProjectClaimTokenRequest(server string, ref string) (*http.Reque var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6400,7 +6411,7 @@ func NewV1CreateProjectClaimTokenRequest(server string, ref string) (*http.Reque return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -6414,7 +6425,7 @@ func NewV1DeleteLoginRolesRequest(server string, ref string) (*http.Request, err var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6434,7 +6445,7 @@ func NewV1DeleteLoginRolesRequest(server string, ref string) (*http.Request, err return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -6459,7 +6470,7 @@ func NewV1CreateLoginRoleRequestWithBody(server string, ref string, contentType var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6479,7 +6490,7 @@ func NewV1CreateLoginRoleRequestWithBody(server string, ref string, contentType return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -6495,7 +6506,7 @@ func NewV1GetAuthServiceConfigRequest(server string, ref string) (*http.Request, var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6515,7 +6526,7 @@ func NewV1GetAuthServiceConfigRequest(server string, ref string) (*http.Request, return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -6540,7 +6551,7 @@ func NewV1UpdateAuthServiceConfigRequestWithBody(server string, ref string, cont var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6560,7 +6571,7 @@ func NewV1UpdateAuthServiceConfigRequestWithBody(server string, ref string, cont return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -6576,7 +6587,7 @@ func NewV1GetProjectSigningKeysRequest(server string, ref string) (*http.Request var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6596,7 +6607,7 @@ func NewV1GetProjectSigningKeysRequest(server string, ref string) (*http.Request return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -6621,7 +6632,7 @@ func NewV1CreateProjectSigningKeyRequestWithBody(server string, ref string, cont var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6641,7 +6652,7 @@ func NewV1CreateProjectSigningKeyRequestWithBody(server string, ref string, cont return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -6657,7 +6668,7 @@ func NewV1GetLegacySigningKeyRequest(server string, ref string) (*http.Request, var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6677,7 +6688,7 @@ func NewV1GetLegacySigningKeyRequest(server string, ref string) (*http.Request, return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -6691,7 +6702,7 @@ func NewV1CreateLegacySigningKeyRequest(server string, ref string) (*http.Reques var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6711,7 +6722,7 @@ func NewV1CreateLegacySigningKeyRequest(server string, ref string) (*http.Reques return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -6725,14 +6736,14 @@ func NewV1RemoveProjectSigningKeyRequest(server string, ref string, id openapi_t var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -6752,7 +6763,7 @@ func NewV1RemoveProjectSigningKeyRequest(server string, ref string, id openapi_t return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -6766,14 +6777,14 @@ func NewV1GetProjectSigningKeyRequest(server string, ref string, id openapi_type var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -6793,7 +6804,7 @@ func NewV1GetProjectSigningKeyRequest(server string, ref string, id openapi_type return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -6818,14 +6829,14 @@ func NewV1UpdateProjectSigningKeyRequestWithBody(server string, ref string, id o var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -6845,7 +6856,7 @@ func NewV1UpdateProjectSigningKeyRequestWithBody(server string, ref string, id o return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -6861,7 +6872,7 @@ func NewV1ListAllSsoProviderRequest(server string, ref string) (*http.Request, e var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6881,7 +6892,7 @@ func NewV1ListAllSsoProviderRequest(server string, ref string) (*http.Request, e return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -6906,7 +6917,7 @@ func NewV1CreateASsoProviderRequestWithBody(server string, ref string, contentTy var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -6926,7 +6937,7 @@ func NewV1CreateASsoProviderRequestWithBody(server string, ref string, contentTy return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -6942,14 +6953,14 @@ func NewV1DeleteASsoProviderRequest(server string, ref string, providerId openap var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "provider_id", runtime.ParamLocationPath, providerId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider_id", providerId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -6969,7 +6980,7 @@ func NewV1DeleteASsoProviderRequest(server string, ref string, providerId openap return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -6983,14 +6994,14 @@ func NewV1GetASsoProviderRequest(server string, ref string, providerId openapi_t var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "provider_id", runtime.ParamLocationPath, providerId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider_id", providerId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -7010,7 +7021,7 @@ func NewV1GetASsoProviderRequest(server string, ref string, providerId openapi_t return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7035,14 +7046,14 @@ func NewV1UpdateASsoProviderRequestWithBody(server string, ref string, providerI var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "provider_id", runtime.ParamLocationPath, providerId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider_id", providerId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -7062,7 +7073,7 @@ func NewV1UpdateASsoProviderRequestWithBody(server string, ref string, providerI return nil, err } - req, err := http.NewRequest("PUT", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) if err != nil { return nil, err } @@ -7078,7 +7089,7 @@ func NewV1ListProjectTpaIntegrationsRequest(server string, ref string) (*http.Re var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7098,7 +7109,7 @@ func NewV1ListProjectTpaIntegrationsRequest(server string, ref string) (*http.Re return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7123,7 +7134,7 @@ func NewV1CreateProjectTpaIntegrationRequestWithBody(server string, ref string, var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7143,7 +7154,7 @@ func NewV1CreateProjectTpaIntegrationRequestWithBody(server string, ref string, return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -7159,14 +7170,14 @@ func NewV1DeleteProjectTpaIntegrationRequest(server string, ref string, tpaId op var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "tpa_id", runtime.ParamLocationPath, tpaId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "tpa_id", tpaId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -7186,7 +7197,7 @@ func NewV1DeleteProjectTpaIntegrationRequest(server string, ref string, tpaId op return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -7200,14 +7211,14 @@ func NewV1GetProjectTpaIntegrationRequest(server string, ref string, tpaId opena var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "tpa_id", runtime.ParamLocationPath, tpaId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "tpa_id", tpaId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -7227,7 +7238,7 @@ func NewV1GetProjectTpaIntegrationRequest(server string, ref string, tpaId opena return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7241,7 +7252,7 @@ func NewV1GetProjectPgbouncerConfigRequest(server string, ref string) (*http.Req var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7261,7 +7272,7 @@ func NewV1GetProjectPgbouncerConfigRequest(server string, ref string) (*http.Req return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7275,7 +7286,7 @@ func NewV1GetPoolerConfigRequest(server string, ref string) (*http.Request, erro var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7295,7 +7306,7 @@ func NewV1GetPoolerConfigRequest(server string, ref string) (*http.Request, erro return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7320,7 +7331,7 @@ func NewV1UpdatePoolerConfigRequestWithBody(server string, ref string, contentTy var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7340,7 +7351,7 @@ func NewV1UpdatePoolerConfigRequestWithBody(server string, ref string, contentTy return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -7356,7 +7367,7 @@ func NewV1GetPostgresConfigRequest(server string, ref string) (*http.Request, er var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7376,7 +7387,7 @@ func NewV1GetPostgresConfigRequest(server string, ref string) (*http.Request, er return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7401,7 +7412,7 @@ func NewV1UpdatePostgresConfigRequestWithBody(server string, ref string, content var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7421,7 +7432,7 @@ func NewV1UpdatePostgresConfigRequestWithBody(server string, ref string, content return nil, err } - req, err := http.NewRequest("PUT", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) if err != nil { return nil, err } @@ -7437,7 +7448,7 @@ func NewV1GetDatabaseDiskRequest(server string, ref string) (*http.Request, erro var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7457,7 +7468,7 @@ func NewV1GetDatabaseDiskRequest(server string, ref string) (*http.Request, erro return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7482,7 +7493,7 @@ func NewV1ModifyDatabaseDiskRequestWithBody(server string, ref string, contentTy var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7502,7 +7513,7 @@ func NewV1ModifyDatabaseDiskRequestWithBody(server string, ref string, contentTy return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -7518,7 +7529,7 @@ func NewV1GetProjectDiskAutoscaleConfigRequest(server string, ref string) (*http var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7538,7 +7549,7 @@ func NewV1GetProjectDiskAutoscaleConfigRequest(server string, ref string) (*http return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7552,7 +7563,7 @@ func NewV1GetDiskUtilizationRequest(server string, ref string) (*http.Request, e var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7572,7 +7583,7 @@ func NewV1GetDiskUtilizationRequest(server string, ref string) (*http.Request, e return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7586,7 +7597,7 @@ func NewV1GetRealtimeConfigRequest(server string, ref string) (*http.Request, er var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7606,7 +7617,7 @@ func NewV1GetRealtimeConfigRequest(server string, ref string) (*http.Request, er return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7631,7 +7642,7 @@ func NewV1UpdateRealtimeConfigRequestWithBody(server string, ref string, content var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7651,7 +7662,7 @@ func NewV1UpdateRealtimeConfigRequestWithBody(server string, ref string, content return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -7667,7 +7678,7 @@ func NewV1ShutdownRealtimeRequest(server string, ref string) (*http.Request, err var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7687,7 +7698,7 @@ func NewV1ShutdownRealtimeRequest(server string, ref string) (*http.Request, err return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -7701,7 +7712,7 @@ func NewV1GetStorageConfigRequest(server string, ref string) (*http.Request, err var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7721,7 +7732,7 @@ func NewV1GetStorageConfigRequest(server string, ref string) (*http.Request, err return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7746,7 +7757,7 @@ func NewV1UpdateStorageConfigRequestWithBody(server string, ref string, contentT var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7766,7 +7777,7 @@ func NewV1UpdateStorageConfigRequestWithBody(server string, ref string, contentT return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -7782,7 +7793,7 @@ func NewV1DeleteHostnameConfigRequest(server string, ref string, params *V1Delet var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7803,28 +7814,33 @@ func NewV1DeleteHostnameConfigRequest(server string, ref string, params *V1Delet } 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.StyleParamWithLocation("form", true, "remove_addon", runtime.ParamLocationQuery, *params.RemoveAddon); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "remove_addon", *params.RemoveAddon, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -7838,7 +7854,7 @@ func NewV1GetHostnameConfigRequest(server string, ref string) (*http.Request, er var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7858,7 +7874,7 @@ func NewV1GetHostnameConfigRequest(server string, ref string) (*http.Request, er return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -7872,7 +7888,7 @@ func NewV1ActivateCustomHostnameRequest(server string, ref string) (*http.Reques var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7892,7 +7908,7 @@ func NewV1ActivateCustomHostnameRequest(server string, ref string) (*http.Reques return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -7917,7 +7933,7 @@ func NewV1UpdateHostnameConfigRequestWithBody(server string, ref string, content var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7937,7 +7953,7 @@ func NewV1UpdateHostnameConfigRequestWithBody(server string, ref string, content return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -7953,7 +7969,7 @@ func NewV1VerifyDnsConfigRequest(server string, ref string) (*http.Request, erro var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -7973,7 +7989,7 @@ func NewV1VerifyDnsConfigRequest(server string, ref string) (*http.Request, erro return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -7987,7 +8003,7 @@ func NewV1ListAllBackupsRequest(server string, ref string) (*http.Request, error var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8007,7 +8023,7 @@ func NewV1ListAllBackupsRequest(server string, ref string) (*http.Request, error return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -8032,7 +8048,7 @@ func NewV1RestorePhysicalBackupRequestWithBody(server string, ref string, conten var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8052,7 +8068,7 @@ func NewV1RestorePhysicalBackupRequestWithBody(server string, ref string, conten return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -8079,7 +8095,7 @@ func NewV1RestorePitrBackupRequestWithBody(server string, ref string, contentTyp var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8099,7 +8115,7 @@ func NewV1RestorePitrBackupRequestWithBody(server string, ref string, contentTyp return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -8115,7 +8131,7 @@ func NewV1GetRestorePointRequest(server string, ref string, params *V1GetRestore var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8136,28 +8152,33 @@ func NewV1GetRestorePointRequest(server string, ref string, params *V1GetRestore } 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.StyleParamWithLocation("form", true, "name", runtime.ParamLocationQuery, *params.Name); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -8182,7 +8203,7 @@ func NewV1CreateRestorePointRequestWithBody(server string, ref string, contentTy var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8202,7 +8223,7 @@ func NewV1CreateRestorePointRequestWithBody(server string, ref string, contentTy return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -8218,7 +8239,7 @@ func NewV1GetBackupScheduleRequest(server string, ref string) (*http.Request, er var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8238,7 +8259,7 @@ func NewV1GetBackupScheduleRequest(server string, ref string) (*http.Request, er return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -8263,7 +8284,7 @@ func NewV1UpdateBackupScheduleRequestWithBody(server string, ref string, content var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8283,7 +8304,7 @@ func NewV1UpdateBackupScheduleRequestWithBody(server string, ref string, content return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -8310,7 +8331,7 @@ func NewV1UndoRequestWithBody(server string, ref string, contentType string, bod var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8330,7 +8351,7 @@ func NewV1UndoRequestWithBody(server string, ref string, contentType string, bod return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -8346,7 +8367,7 @@ func NewV1GetDatabaseMetadataRequest(server string, ref string) (*http.Request, var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8366,7 +8387,7 @@ func NewV1GetDatabaseMetadataRequest(server string, ref string) (*http.Request, return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -8380,7 +8401,7 @@ func NewV1GetJitAccessRequest(server string, ref string) (*http.Request, error) var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8400,7 +8421,7 @@ func NewV1GetJitAccessRequest(server string, ref string) (*http.Request, error) return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -8425,7 +8446,7 @@ func NewV1AuthorizeJitAccessRequestWithBody(server string, ref string, contentTy var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8445,7 +8466,7 @@ func NewV1AuthorizeJitAccessRequestWithBody(server string, ref string, contentTy return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -8472,7 +8493,7 @@ func NewV1UpdateJitAccessRequestWithBody(server string, ref string, contentType var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8492,7 +8513,7 @@ func NewV1UpdateJitAccessRequestWithBody(server string, ref string, contentType return nil, err } - req, err := http.NewRequest("PUT", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) if err != nil { return nil, err } @@ -8519,7 +8540,7 @@ func NewV1InviteExternalJitAccessRequestWithBody(server string, ref string, cont var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8539,7 +8560,7 @@ func NewV1InviteExternalJitAccessRequestWithBody(server string, ref string, cont return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -8566,7 +8587,7 @@ func NewV1AcceptInviteExternalJitAccessRequestWithBody(server string, ref string var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8586,7 +8607,7 @@ func NewV1AcceptInviteExternalJitAccessRequestWithBody(server string, ref string return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -8602,14 +8623,14 @@ func NewV1DeleteInviteExternalJitAccessRequest(server string, ref string, invite var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "invite_id", runtime.ParamLocationPath, inviteId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "invite_id", inviteId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -8629,7 +8650,7 @@ func NewV1DeleteInviteExternalJitAccessRequest(server string, ref string, invite return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -8643,7 +8664,7 @@ func NewV1ListJitAccessRequest(server string, ref string) (*http.Request, error) var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8663,7 +8684,7 @@ func NewV1ListJitAccessRequest(server string, ref string) (*http.Request, error) return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -8677,14 +8698,14 @@ func NewV1DeleteJitAccessRequest(server string, ref string, userId openapi_types var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "user_id", runtime.ParamLocationPath, userId) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "user_id", userId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -8704,7 +8725,7 @@ func NewV1DeleteJitAccessRequest(server string, ref string, userId openapi_types return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -8718,7 +8739,7 @@ func NewV1RollbackMigrationsRequest(server string, ref string, params *V1Rollbac var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8739,24 +8760,29 @@ func NewV1RollbackMigrationsRequest(server string, ref string, params *V1Rollbac } 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.StyleParamWithLocation("form", true, "gte", runtime.ParamLocationQuery, params.Gte); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -8770,7 +8796,7 @@ func NewV1ListMigrationHistoryRequest(server string, ref string) (*http.Request, var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8790,7 +8816,7 @@ func NewV1ListMigrationHistoryRequest(server string, ref string) (*http.Request, return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -8815,7 +8841,7 @@ func NewV1ApplyAMigrationRequestWithBody(server string, ref string, params *V1Ap var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8835,7 +8861,7 @@ func NewV1ApplyAMigrationRequestWithBody(server string, ref string, params *V1Ap return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -8847,7 +8873,7 @@ func NewV1ApplyAMigrationRequestWithBody(server string, ref string, params *V1Ap if params.IdempotencyKey != nil { var headerParam0 string - headerParam0, err = runtime.StyleParamWithLocation("simple", false, "Idempotency-Key", runtime.ParamLocationHeader, *params.IdempotencyKey) + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Idempotency-Key", *params.IdempotencyKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8877,7 +8903,7 @@ func NewV1UpsertAMigrationRequestWithBody(server string, ref string, params *V1U var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8897,7 +8923,7 @@ func NewV1UpsertAMigrationRequestWithBody(server string, ref string, params *V1U return nil, err } - req, err := http.NewRequest("PUT", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) if err != nil { return nil, err } @@ -8909,7 +8935,7 @@ func NewV1UpsertAMigrationRequestWithBody(server string, ref string, params *V1U if params.IdempotencyKey != nil { var headerParam0 string - headerParam0, err = runtime.StyleParamWithLocation("simple", false, "Idempotency-Key", runtime.ParamLocationHeader, *params.IdempotencyKey) + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Idempotency-Key", *params.IdempotencyKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8928,14 +8954,14 @@ func NewV1GetAMigrationRequest(server string, ref string, version string) (*http var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "version", runtime.ParamLocationPath, version) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "version", version, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -8955,7 +8981,7 @@ func NewV1GetAMigrationRequest(server string, ref string, version string) (*http return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -8980,14 +9006,14 @@ func NewV1PatchAMigrationRequestWithBody(server string, ref string, version stri var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "version", runtime.ParamLocationPath, version) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "version", version, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9007,7 +9033,7 @@ func NewV1PatchAMigrationRequestWithBody(server string, ref string, version stri return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -9023,7 +9049,7 @@ func NewV1GetDatabaseOpenapiRequest(server string, ref string, params *V1GetData var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9044,28 +9070,33 @@ func NewV1GetDatabaseOpenapiRequest(server string, ref string, params *V1GetData } 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.StyleParamWithLocation("form", true, "schema", runtime.ParamLocationQuery, *params.Schema); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -9090,7 +9121,7 @@ func NewV1UpdateDatabasePasswordRequestWithBody(server string, ref string, conte var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9110,7 +9141,7 @@ func NewV1UpdateDatabasePasswordRequestWithBody(server string, ref string, conte return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -9137,7 +9168,7 @@ func NewV1RunAQueryRequestWithBody(server string, ref string, contentType string var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9157,7 +9188,7 @@ func NewV1RunAQueryRequestWithBody(server string, ref string, contentType string return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -9184,7 +9215,7 @@ func NewV1ReadOnlyQueryRequestWithBody(server string, ref string, contentType st var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9204,7 +9235,7 @@ func NewV1ReadOnlyQueryRequestWithBody(server string, ref string, contentType st return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -9220,7 +9251,7 @@ func NewV1EnableDatabaseWebhookRequest(server string, ref string) (*http.Request var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9240,7 +9271,7 @@ func NewV1EnableDatabaseWebhookRequest(server string, ref string) (*http.Request return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -9254,7 +9285,7 @@ func NewV1ListAllFunctionsRequest(server string, ref string) (*http.Request, err var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9274,7 +9305,7 @@ func NewV1ListAllFunctionsRequest(server string, ref string) (*http.Request, err return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -9299,7 +9330,7 @@ func NewV1CreateAFunctionRequestWithBody(server string, ref string, params *V1Cr var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9320,19 +9351,21 @@ func NewV1CreateAFunctionRequestWithBody(server string, ref string, params *V1Cr } 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.StyleParamWithLocation("form", true, "slug", runtime.ParamLocationQuery, *params.Slug); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9340,15 +9373,11 @@ func NewV1CreateAFunctionRequestWithBody(server string, ref string, params *V1Cr if params.Name != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "name", runtime.ParamLocationQuery, *params.Name); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9356,15 +9385,11 @@ func NewV1CreateAFunctionRequestWithBody(server string, ref string, params *V1Cr if params.VerifyJwt != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "verify_jwt", runtime.ParamLocationQuery, *params.VerifyJwt); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "verify_jwt", *params.VerifyJwt, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9372,15 +9397,11 @@ func NewV1CreateAFunctionRequestWithBody(server string, ref string, params *V1Cr if params.ImportMap != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "import_map", runtime.ParamLocationQuery, *params.ImportMap); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "import_map", *params.ImportMap, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9388,15 +9409,11 @@ func NewV1CreateAFunctionRequestWithBody(server string, ref string, params *V1Cr if params.EntrypointPath != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "entrypoint_path", runtime.ParamLocationQuery, *params.EntrypointPath); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9404,15 +9421,11 @@ func NewV1CreateAFunctionRequestWithBody(server string, ref string, params *V1Cr if params.ImportMapPath != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "import_map_path", runtime.ParamLocationQuery, *params.ImportMapPath); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9420,24 +9433,23 @@ func NewV1CreateAFunctionRequestWithBody(server string, ref string, params *V1Cr if params.EzbrSha256 != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "ezbr_sha256", runtime.ParamLocationQuery, *params.EzbrSha256); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -9464,7 +9476,7 @@ func NewV1BulkUpdateFunctionsRequestWithBody(server string, ref string, contentT var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9484,7 +9496,7 @@ func NewV1BulkUpdateFunctionsRequestWithBody(server string, ref string, contentT return nil, err } - req, err := http.NewRequest("PUT", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) if err != nil { return nil, err } @@ -9500,7 +9512,7 @@ func NewV1DeployAFunctionRequestWithBody(server string, ref string, params *V1De var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9521,19 +9533,21 @@ func NewV1DeployAFunctionRequestWithBody(server string, ref string, params *V1De } 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.StyleParamWithLocation("form", true, "slug", runtime.ParamLocationQuery, *params.Slug); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9541,24 +9555,23 @@ func NewV1DeployAFunctionRequestWithBody(server string, ref string, params *V1De if params.BundleOnly != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "bundleOnly", runtime.ParamLocationQuery, *params.BundleOnly); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "bundleOnly", *params.BundleOnly, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -9574,14 +9587,14 @@ func NewV1DeleteAFunctionRequest(server string, ref string, functionSlug string) var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "function_slug", runtime.ParamLocationPath, functionSlug) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "function_slug", functionSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9601,7 +9614,7 @@ func NewV1DeleteAFunctionRequest(server string, ref string, functionSlug string) return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -9615,14 +9628,14 @@ func NewV1GetAFunctionRequest(server string, ref string, functionSlug string) (* var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "function_slug", runtime.ParamLocationPath, functionSlug) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "function_slug", functionSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9642,7 +9655,7 @@ func NewV1GetAFunctionRequest(server string, ref string, functionSlug string) (* return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -9667,14 +9680,14 @@ func NewV1UpdateAFunctionRequestWithBody(server string, ref string, functionSlug var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "function_slug", runtime.ParamLocationPath, functionSlug) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "function_slug", functionSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9695,19 +9708,21 @@ func NewV1UpdateAFunctionRequestWithBody(server string, ref string, functionSlug } 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.StyleParamWithLocation("form", true, "slug", runtime.ParamLocationQuery, *params.Slug); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9715,15 +9730,11 @@ func NewV1UpdateAFunctionRequestWithBody(server string, ref string, functionSlug if params.Name != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "name", runtime.ParamLocationQuery, *params.Name); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9731,15 +9742,11 @@ func NewV1UpdateAFunctionRequestWithBody(server string, ref string, functionSlug if params.VerifyJwt != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "verify_jwt", runtime.ParamLocationQuery, *params.VerifyJwt); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "verify_jwt", *params.VerifyJwt, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9747,15 +9754,11 @@ func NewV1UpdateAFunctionRequestWithBody(server string, ref string, functionSlug if params.ImportMap != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "import_map", runtime.ParamLocationQuery, *params.ImportMap); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "import_map", *params.ImportMap, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { return nil, err } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9763,15 +9766,11 @@ func NewV1UpdateAFunctionRequestWithBody(server string, ref string, functionSlug if params.EntrypointPath != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "entrypoint_path", runtime.ParamLocationQuery, *params.EntrypointPath); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9779,15 +9778,11 @@ func NewV1UpdateAFunctionRequestWithBody(server string, ref string, functionSlug if params.ImportMapPath != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "import_map_path", runtime.ParamLocationQuery, *params.ImportMapPath); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -9795,24 +9790,23 @@ func NewV1UpdateAFunctionRequestWithBody(server string, ref string, functionSlug if params.EzbrSha256 != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "ezbr_sha256", runtime.ParamLocationQuery, *params.EzbrSha256); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -9828,14 +9822,14 @@ func NewV1GetAFunctionBodyRequest(server string, ref string, functionSlug string var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + 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.StyleParamWithLocation("simple", false, "function_slug", runtime.ParamLocationPath, functionSlug) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "function_slug", functionSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9855,7 +9849,7 @@ func NewV1GetAFunctionBodyRequest(server string, ref string, functionSlug string return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -9869,7 +9863,7 @@ func NewV1GetServicesHealthRequest(server string, ref string, params *V1GetServi var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9890,40 +9884,45 @@ func NewV1GetServicesHealthRequest(server string, ref string, params *V1GetServi } 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.StyleParamWithLocation("form", true, "services", runtime.ParamLocationQuery, params.Services); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { - return nil, err - } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) + 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.StyleParamWithLocation("form", true, "timeout_ms", runtime.ParamLocationQuery, *params.TimeoutMs); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -9937,7 +9936,7 @@ func NewV1GetJitAccessConfigRequest(server string, ref string) (*http.Request, e var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -9957,7 +9956,7 @@ func NewV1GetJitAccessConfigRequest(server string, ref string) (*http.Request, e return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -9982,7 +9981,7 @@ func NewV1UpdateJitAccessConfigRequestWithBody(server string, ref string, conten var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10002,7 +10001,7 @@ func NewV1UpdateJitAccessConfigRequestWithBody(server string, ref string, conten return nil, err } - req, err := http.NewRequest("PUT", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) if err != nil { return nil, err } @@ -10029,7 +10028,7 @@ func NewV1DeleteNetworkBansRequestWithBody(server string, ref string, contentTyp var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10049,7 +10048,7 @@ func NewV1DeleteNetworkBansRequestWithBody(server string, ref string, contentTyp return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), body) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), body) if err != nil { return nil, err } @@ -10065,7 +10064,7 @@ func NewV1ListAllNetworkBansRequest(server string, ref string) (*http.Request, e var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10085,7 +10084,7 @@ func NewV1ListAllNetworkBansRequest(server string, ref string) (*http.Request, e return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -10099,7 +10098,7 @@ func NewV1ListAllNetworkBansEnrichedRequest(server string, ref string) (*http.Re var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10119,7 +10118,7 @@ func NewV1ListAllNetworkBansEnrichedRequest(server string, ref string) (*http.Re return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -10133,7 +10132,7 @@ func NewV1GetNetworkRestrictionsRequest(server string, ref string) (*http.Reques var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10153,7 +10152,7 @@ func NewV1GetNetworkRestrictionsRequest(server string, ref string) (*http.Reques return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -10178,7 +10177,7 @@ func NewV1PatchNetworkRestrictionsRequestWithBody(server string, ref string, con var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10198,7 +10197,7 @@ func NewV1PatchNetworkRestrictionsRequestWithBody(server string, ref string, con return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -10225,7 +10224,7 @@ func NewV1UpdateNetworkRestrictionsRequestWithBody(server string, ref string, co var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10245,7 +10244,7 @@ func NewV1UpdateNetworkRestrictionsRequestWithBody(server string, ref string, co return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -10261,7 +10260,7 @@ func NewV1PauseAProjectRequest(server string, ref string) (*http.Request, error) var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10281,7 +10280,7 @@ func NewV1PauseAProjectRequest(server string, ref string) (*http.Request, error) return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -10295,7 +10294,7 @@ func NewV1GetPgsodiumConfigRequest(server string, ref string) (*http.Request, er var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10315,7 +10314,7 @@ func NewV1GetPgsodiumConfigRequest(server string, ref string) (*http.Request, er return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -10340,7 +10339,7 @@ func NewV1UpdatePgsodiumConfigRequestWithBody(server string, ref string, content var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10360,7 +10359,7 @@ func NewV1UpdatePgsodiumConfigRequestWithBody(server string, ref string, content return nil, err } - req, err := http.NewRequest("PUT", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) if err != nil { return nil, err } @@ -10376,7 +10375,7 @@ func NewV1GetPostgrestServiceConfigRequest(server string, ref string) (*http.Req var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10396,7 +10395,7 @@ func NewV1GetPostgrestServiceConfigRequest(server string, ref string) (*http.Req return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -10421,7 +10420,7 @@ func NewV1UpdatePostgrestServiceConfigRequestWithBody(server string, ref string, var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10441,7 +10440,7 @@ func NewV1UpdatePostgrestServiceConfigRequestWithBody(server string, ref string, return nil, err } - req, err := http.NewRequest("PATCH", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) if err != nil { return nil, err } @@ -10468,7 +10467,7 @@ func NewV1RemoveAReadReplicaRequestWithBody(server string, ref string, contentTy var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10488,7 +10487,7 @@ func NewV1RemoveAReadReplicaRequestWithBody(server string, ref string, contentTy return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -10515,7 +10514,7 @@ func NewV1SetupAReadReplicaRequestWithBody(server string, ref string, contentTyp var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10535,7 +10534,7 @@ func NewV1SetupAReadReplicaRequestWithBody(server string, ref string, contentTyp return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -10551,7 +10550,7 @@ func NewV1GetReadonlyModeStatusRequest(server string, ref string) (*http.Request var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10571,7 +10570,7 @@ func NewV1GetReadonlyModeStatusRequest(server string, ref string) (*http.Request return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -10585,7 +10584,7 @@ func NewV1DisableReadonlyModeTemporarilyRequest(server string, ref string) (*htt var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10605,7 +10604,7 @@ func NewV1DisableReadonlyModeTemporarilyRequest(server string, ref string) (*htt return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -10619,7 +10618,7 @@ func NewV1RestartAProjectRequest(server string, ref string) (*http.Request, erro var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10639,7 +10638,7 @@ func NewV1RestartAProjectRequest(server string, ref string) (*http.Request, erro return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -10653,7 +10652,7 @@ func NewV1ListAvailableRestoreVersionsRequest(server string, ref string) (*http. var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10673,7 +10672,7 @@ func NewV1ListAvailableRestoreVersionsRequest(server string, ref string) (*http. return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -10687,7 +10686,7 @@ func NewV1RestoreAProjectRequest(server string, ref string) (*http.Request, erro var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10707,7 +10706,7 @@ func NewV1RestoreAProjectRequest(server string, ref string) (*http.Request, erro return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -10721,7 +10720,7 @@ func NewV1CancelAProjectRestorationRequest(server string, ref string) (*http.Req var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10741,7 +10740,7 @@ func NewV1CancelAProjectRestorationRequest(server string, ref string) (*http.Req return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) if err != nil { return nil, err } @@ -10766,7 +10765,7 @@ func NewV1BulkDeleteSecretsRequestWithBody(server string, ref string, contentTyp var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10786,7 +10785,7 @@ func NewV1BulkDeleteSecretsRequestWithBody(server string, ref string, contentTyp return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), body) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), body) if err != nil { return nil, err } @@ -10802,7 +10801,7 @@ func NewV1ListAllSecretsRequest(server string, ref string) (*http.Request, error var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10822,7 +10821,7 @@ func NewV1ListAllSecretsRequest(server string, ref string) (*http.Request, error return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -10847,7 +10846,7 @@ func NewV1BulkCreateSecretsRequestWithBody(server string, ref string, contentTyp var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10867,7 +10866,7 @@ func NewV1BulkCreateSecretsRequestWithBody(server string, ref string, contentTyp return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -10883,7 +10882,7 @@ func NewV1GetSslEnforcementConfigRequest(server string, ref string) (*http.Reque var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10903,7 +10902,7 @@ func NewV1GetSslEnforcementConfigRequest(server string, ref string) (*http.Reque return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -10928,7 +10927,7 @@ func NewV1UpdateSslEnforcementConfigRequestWithBody(server string, ref string, c var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10948,7 +10947,7 @@ func NewV1UpdateSslEnforcementConfigRequestWithBody(server string, ref string, c return nil, err } - req, err := http.NewRequest("PUT", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) if err != nil { return nil, err } @@ -10964,7 +10963,7 @@ func NewV1ListAllBucketsRequest(server string, ref string) (*http.Request, error var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -10984,7 +10983,7 @@ func NewV1ListAllBucketsRequest(server string, ref string) (*http.Request, error return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -10998,7 +10997,7 @@ func NewV1GenerateTypescriptTypesRequest(server string, ref string, params *V1Ge var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -11019,28 +11018,33 @@ func NewV1GenerateTypescriptTypesRequest(server string, ref string, params *V1Ge } 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.StyleParamWithLocation("form", true, "included_schemas", runtime.ParamLocationQuery, *params.IncludedSchemas); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -11065,7 +11069,7 @@ func NewV1UpgradePostgresVersionRequestWithBody(server string, ref string, conte var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -11085,7 +11089,7 @@ func NewV1UpgradePostgresVersionRequestWithBody(server string, ref string, conte return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -11101,7 +11105,7 @@ func NewV1GetPostgresUpgradeEligibilityRequest(server string, ref string) (*http var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -11121,7 +11125,7 @@ func NewV1GetPostgresUpgradeEligibilityRequest(server string, ref string) (*http return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -11135,7 +11139,7 @@ func NewV1GetPostgresUpgradeStatusRequest(server string, ref string, params *V1G var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -11156,28 +11160,33 @@ func NewV1GetPostgresUpgradeStatusRequest(server string, ref string, params *V1G } 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.StyleParamWithLocation("form", true, "tracking_id", runtime.ParamLocationQuery, *params.TrackingId); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -11191,7 +11200,7 @@ func NewV1DeactivateVanitySubdomainConfigRequest(server string, ref string) (*ht var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -11211,7 +11220,7 @@ func NewV1DeactivateVanitySubdomainConfigRequest(server string, ref string) (*ht return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err } @@ -11225,7 +11234,7 @@ func NewV1GetVanitySubdomainConfigRequest(server string, ref string) (*http.Requ var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -11245,7 +11254,7 @@ func NewV1GetVanitySubdomainConfigRequest(server string, ref string) (*http.Requ return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -11270,7 +11279,7 @@ func NewV1ActivateVanitySubdomainConfigRequestWithBody(server string, ref string var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -11290,7 +11299,7 @@ func NewV1ActivateVanitySubdomainConfigRequestWithBody(server string, ref string return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -11317,7 +11326,7 @@ func NewV1CheckVanitySubdomainAvailabilityRequestWithBody(server string, ref str var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -11337,7 +11346,7 @@ func NewV1CheckVanitySubdomainAvailabilityRequestWithBody(server string, ref str return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } @@ -11367,19 +11376,21 @@ func NewV1ListAllSnippetsRequest(server string, params *V1ListAllSnippetsParams) } 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.StyleParamWithLocation("form", true, "project_ref", runtime.ParamLocationQuery, *params.ProjectRef); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -11387,15 +11398,11 @@ func NewV1ListAllSnippetsRequest(server string, params *V1ListAllSnippetsParams) if params.Cursor != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "cursor", runtime.ParamLocationQuery, *params.Cursor); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -11403,15 +11410,11 @@ func NewV1ListAllSnippetsRequest(server string, params *V1ListAllSnippetsParams) if params.Limit != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -11419,15 +11422,11 @@ func NewV1ListAllSnippetsRequest(server string, params *V1ListAllSnippetsParams) if params.SortBy != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "sort_by", runtime.ParamLocationQuery, *params.SortBy); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } @@ -11435,24 +11434,23 @@ func NewV1ListAllSnippetsRequest(server string, params *V1ListAllSnippetsParams) if params.SortOrder != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "sort_order", runtime.ParamLocationQuery, *params.SortOrder); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != 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 k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } } } - queryURL.RawQuery = queryValues.Encode() + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -11466,7 +11464,7 @@ func NewV1GetASnippetRequest(server string, id openapi_types.UUID) (*http.Reques var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) if err != nil { return nil, err } @@ -11486,7 +11484,7 @@ func NewV1GetASnippetRequest(server string, id openapi_types.UUID) (*http.Reques return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -11668,6 +11666,9 @@ type ClientWithResponsesInterface interface { // V1GetProjectLogsAllWithResponse request V1GetProjectLogsAllWithResponse(ctx context.Context, ref string, params *V1GetProjectLogsAllParams, 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) @@ -12187,6 +12188,14 @@ func (r V1DeleteABranchResponse) StatusCode() int { 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 @@ -12209,6 +12218,14 @@ func (r V1GetABranchConfigResponse) StatusCode() int { 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 @@ -12231,6 +12248,14 @@ func (r V1UpdateABranchConfigResponse) StatusCode() int { 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 @@ -12252,6 +12277,14 @@ func (r V1DiffABranchResponse) StatusCode() int { 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 @@ -12274,6 +12307,14 @@ func (r V1MergeABranchResponse) StatusCode() int { 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 @@ -12296,6 +12337,14 @@ func (r V1PushABranchResponse) StatusCode() int { 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 @@ -12318,6 +12367,14 @@ func (r V1ResetABranchResponse) StatusCode() int { 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 @@ -12340,6 +12397,14 @@ func (r V1RestoreABranchResponse) StatusCode() int { 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 @@ -12361,6 +12426,14 @@ func (r V1AuthorizeUserResponse) StatusCode() int { 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 @@ -12382,6 +12455,14 @@ func (r V1OauthAuthorizeProjectClaimResponse) StatusCode() int { 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 @@ -12403,6 +12484,14 @@ func (r V1RevokeTokenResponse) StatusCode() int { 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 @@ -12425,6 +12514,14 @@ func (r V1ExchangeOauthTokenResponse) StatusCode() int { 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 @@ -12447,6 +12544,14 @@ func (r V1ListAllOrganizationsResponse) StatusCode() int { 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 @@ -12469,6 +12574,14 @@ func (r V1CreateAnOrganizationResponse) StatusCode() int { 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 @@ -12491,6 +12604,14 @@ func (r V1GetAnOrganizationResponse) StatusCode() int { 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 @@ -12513,6 +12634,14 @@ func (r V1GetOrganizationEntitlementsResponse) StatusCode() int { 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 @@ -12535,6 +12664,14 @@ func (r V1ListOrganizationMembersResponse) StatusCode() int { 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 @@ -12557,6 +12694,14 @@ func (r V1GetOrganizationProjectClaimResponse) StatusCode() int { 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 @@ -12578,6 +12723,14 @@ func (r V1ClaimProjectForOrganizationResponse) StatusCode() int { 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 @@ -12600,6 +12753,14 @@ func (r V1GetAllProjectsForOrganizationResponse) StatusCode() int { 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 @@ -12622,6 +12783,14 @@ func (r V1GetProfileResponse) StatusCode() int { 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 @@ -12644,6 +12813,14 @@ func (r V1ListAllProjectsResponse) StatusCode() int { 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 @@ -12666,7 +12843,15 @@ func (r V1CreateAProjectResponse) StatusCode() int { return 0 } -type V1GetAvailableRegionsResponse struct { +// 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 *RegionsInfo @@ -12688,6 +12873,14 @@ func (r V1GetAvailableRegionsResponse) StatusCode() int { 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 @@ -12710,6 +12903,14 @@ func (r V1DeleteAProjectResponse) StatusCode() int { 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 @@ -12732,6 +12933,14 @@ func (r V1GetProjectResponse) StatusCode() int { 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 @@ -12754,6 +12963,14 @@ func (r V1UpdateAProjectResponse) StatusCode() int { 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 @@ -12776,6 +12993,14 @@ func (r V1ListActionRunsResponse) StatusCode() int { 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 @@ -12797,6 +13022,14 @@ func (r V1CountActionRunsResponse) StatusCode() int { 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 @@ -12819,6 +13052,14 @@ func (r V1GetActionRunResponse) StatusCode() int { 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 @@ -12840,6 +13081,14 @@ func (r V1GetActionRunLogsResponse) StatusCode() int { 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 @@ -12862,6 +13111,14 @@ func (r V1UpdateActionRunStatusResponse) StatusCode() int { 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 @@ -12884,6 +13141,14 @@ func (r V1GetPerformanceAdvisorsResponse) StatusCode() int { 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 @@ -12906,6 +13171,14 @@ func (r V1GetSecurityAdvisorsResponse) StatusCode() int { 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 @@ -12928,6 +13201,14 @@ func (r V1GetProjectFunctionCombinedStatsResponse) StatusCode() int { 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 @@ -12950,6 +13231,14 @@ func (r V1GetProjectLogsResponse) StatusCode() int { 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 @@ -12972,6 +13261,43 @@ func (r V1GetProjectLogsAllResponse) StatusCode() int { 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 @@ -12994,6 +13320,14 @@ func (r V1GetProjectUsageApiCountResponse) StatusCode() int { 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 @@ -13016,6 +13350,14 @@ func (r V1GetProjectUsageRequestCountResponse) StatusCode() int { 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 @@ -13038,6 +13380,14 @@ func (r V1GetProjectApiKeysResponse) StatusCode() int { 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 @@ -13060,6 +13410,14 @@ func (r V1CreateProjectApiKeyResponse) StatusCode() int { 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 @@ -13082,6 +13440,14 @@ func (r V1GetProjectLegacyApiKeysResponse) StatusCode() int { 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 @@ -13104,6 +13470,14 @@ func (r V1UpdateProjectLegacyApiKeysResponse) StatusCode() int { 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 @@ -13126,6 +13500,14 @@ func (r V1DeleteProjectApiKeyResponse) StatusCode() int { 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 @@ -13148,6 +13530,14 @@ func (r V1GetProjectApiKeyResponse) StatusCode() int { 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 @@ -13170,6 +13560,14 @@ func (r V1UpdateProjectApiKeyResponse) StatusCode() int { 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 @@ -13192,6 +13590,14 @@ func (r V1ListProjectAddonsResponse) StatusCode() int { 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 @@ -13213,6 +13619,14 @@ func (r V1ApplyProjectAddonResponse) StatusCode() int { 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 @@ -13234,6 +13648,14 @@ func (r V1RemoveProjectAddonResponse) StatusCode() int { 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 @@ -13255,6 +13677,14 @@ func (r V1DisablePreviewBranchingResponse) StatusCode() int { 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 @@ -13277,6 +13707,14 @@ func (r V1ListAllBranchesResponse) StatusCode() int { 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 @@ -13299,6 +13737,14 @@ func (r V1CreateABranchResponse) StatusCode() int { 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 @@ -13321,6 +13767,14 @@ func (r V1GetABranchResponse) StatusCode() int { 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 @@ -13342,6 +13796,14 @@ func (r V1DeleteProjectClaimTokenResponse) StatusCode() int { 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 @@ -13364,6 +13826,14 @@ func (r V1GetProjectClaimTokenResponse) StatusCode() int { 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 @@ -13386,6 +13856,14 @@ func (r V1CreateProjectClaimTokenResponse) StatusCode() int { 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 @@ -13408,6 +13886,14 @@ func (r V1DeleteLoginRolesResponse) StatusCode() int { 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 @@ -13430,6 +13916,14 @@ func (r V1CreateLoginRoleResponse) StatusCode() int { 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 @@ -13452,6 +13946,14 @@ func (r V1GetAuthServiceConfigResponse) StatusCode() int { 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 @@ -13474,6 +13976,14 @@ func (r V1UpdateAuthServiceConfigResponse) StatusCode() int { 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 @@ -13496,6 +14006,14 @@ func (r V1GetProjectSigningKeysResponse) StatusCode() int { 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 @@ -13518,6 +14036,14 @@ func (r V1CreateProjectSigningKeyResponse) StatusCode() int { 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 @@ -13540,6 +14066,14 @@ func (r V1GetLegacySigningKeyResponse) StatusCode() int { 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 @@ -13562,6 +14096,14 @@ func (r V1CreateLegacySigningKeyResponse) StatusCode() int { 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 @@ -13584,6 +14126,14 @@ func (r V1RemoveProjectSigningKeyResponse) StatusCode() int { 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 @@ -13606,6 +14156,14 @@ func (r V1GetProjectSigningKeyResponse) StatusCode() int { 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 @@ -13628,6 +14186,14 @@ func (r V1UpdateProjectSigningKeyResponse) StatusCode() int { 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 @@ -13650,6 +14216,14 @@ func (r V1ListAllSsoProviderResponse) StatusCode() int { 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 @@ -13672,6 +14246,14 @@ func (r V1CreateASsoProviderResponse) StatusCode() int { 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 @@ -13694,6 +14276,14 @@ func (r V1DeleteASsoProviderResponse) StatusCode() int { 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 @@ -13716,6 +14306,14 @@ func (r V1GetASsoProviderResponse) StatusCode() int { 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 @@ -13738,6 +14336,14 @@ func (r V1UpdateASsoProviderResponse) StatusCode() int { 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 @@ -13760,6 +14366,14 @@ func (r V1ListProjectTpaIntegrationsResponse) StatusCode() int { 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 @@ -13782,6 +14396,14 @@ func (r V1CreateProjectTpaIntegrationResponse) StatusCode() int { 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 @@ -13804,6 +14426,14 @@ func (r V1DeleteProjectTpaIntegrationResponse) StatusCode() int { 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 @@ -13826,6 +14456,14 @@ func (r V1GetProjectTpaIntegrationResponse) StatusCode() int { 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 @@ -13848,6 +14486,14 @@ func (r V1GetProjectPgbouncerConfigResponse) StatusCode() int { 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 @@ -13870,6 +14516,14 @@ func (r V1GetPoolerConfigResponse) StatusCode() int { 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 @@ -13892,6 +14546,14 @@ func (r V1UpdatePoolerConfigResponse) StatusCode() int { 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 @@ -13914,6 +14576,14 @@ func (r V1GetPostgresConfigResponse) StatusCode() int { 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 @@ -13936,6 +14606,14 @@ func (r V1UpdatePostgresConfigResponse) StatusCode() int { 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 @@ -13958,6 +14636,14 @@ func (r V1GetDatabaseDiskResponse) StatusCode() int { 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 @@ -13979,6 +14665,14 @@ func (r V1ModifyDatabaseDiskResponse) StatusCode() int { 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 @@ -14001,6 +14695,14 @@ func (r V1GetProjectDiskAutoscaleConfigResponse) StatusCode() int { 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 @@ -14023,6 +14725,14 @@ func (r V1GetDiskUtilizationResponse) StatusCode() int { 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 @@ -14045,6 +14755,14 @@ func (r V1GetRealtimeConfigResponse) StatusCode() int { 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 @@ -14066,6 +14784,14 @@ func (r V1UpdateRealtimeConfigResponse) StatusCode() int { 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 @@ -14087,6 +14813,14 @@ func (r V1ShutdownRealtimeResponse) StatusCode() int { 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 @@ -14109,6 +14843,14 @@ func (r V1GetStorageConfigResponse) StatusCode() int { 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 @@ -14130,6 +14872,14 @@ func (r V1UpdateStorageConfigResponse) StatusCode() int { 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 @@ -14151,6 +14901,14 @@ func (r V1DeleteHostnameConfigResponse) StatusCode() int { 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 @@ -14173,6 +14931,14 @@ func (r V1GetHostnameConfigResponse) StatusCode() int { 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 @@ -14195,6 +14961,14 @@ func (r V1ActivateCustomHostnameResponse) StatusCode() int { 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 @@ -14217,6 +14991,14 @@ func (r V1UpdateHostnameConfigResponse) StatusCode() int { 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 @@ -14239,6 +15021,14 @@ func (r V1VerifyDnsConfigResponse) StatusCode() int { 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 @@ -14261,6 +15051,14 @@ func (r V1ListAllBackupsResponse) StatusCode() int { 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 @@ -14282,6 +15080,14 @@ func (r V1RestorePhysicalBackupResponse) StatusCode() int { 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 @@ -14303,6 +15109,14 @@ func (r V1RestorePitrBackupResponse) StatusCode() int { 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 @@ -14325,6 +15139,14 @@ func (r V1GetRestorePointResponse) StatusCode() int { 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 @@ -14347,10 +15169,19 @@ func (r V1CreateRestorePointResponse) StatusCode() int { 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 *V1BackupScheduleResponse + JSON402 *PlanGateErrorBody } // Status returns HTTPResponse.Status @@ -14369,10 +15200,19 @@ func (r V1GetBackupScheduleResponse) StatusCode() int { 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 *V1BackupScheduleResponse + JSON402 *PlanGateErrorBody } // Status returns HTTPResponse.Status @@ -14391,6 +15231,14 @@ func (r V1UpdateBackupScheduleResponse) StatusCode() int { 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 @@ -14412,6 +15260,14 @@ func (r V1UndoResponse) StatusCode() int { 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 @@ -14434,6 +15290,14 @@ func (r V1GetDatabaseMetadataResponse) StatusCode() int { 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 @@ -14456,6 +15320,14 @@ func (r V1GetJitAccessResponse) StatusCode() int { 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 @@ -14478,6 +15350,14 @@ func (r V1AuthorizeJitAccessResponse) StatusCode() int { 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 @@ -14500,6 +15380,14 @@ func (r V1UpdateJitAccessResponse) StatusCode() int { 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 @@ -14522,6 +15410,14 @@ func (r V1InviteExternalJitAccessResponse) StatusCode() int { 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 @@ -14544,6 +15440,14 @@ func (r V1AcceptInviteExternalJitAccessResponse) StatusCode() int { 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 @@ -14565,6 +15469,14 @@ func (r V1DeleteInviteExternalJitAccessResponse) StatusCode() int { 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 @@ -14587,6 +15499,14 @@ func (r V1ListJitAccessResponse) StatusCode() int { 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 @@ -14608,6 +15528,14 @@ func (r V1DeleteJitAccessResponse) StatusCode() int { 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 @@ -14629,6 +15557,14 @@ func (r V1RollbackMigrationsResponse) StatusCode() int { 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 @@ -14651,6 +15587,14 @@ func (r V1ListMigrationHistoryResponse) StatusCode() int { 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 @@ -14672,6 +15616,14 @@ func (r V1ApplyAMigrationResponse) StatusCode() int { 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 @@ -14693,6 +15645,14 @@ func (r V1UpsertAMigrationResponse) StatusCode() int { 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 @@ -14710,9 +15670,17 @@ func (r V1GetAMigrationResponse) Status() string { // StatusCode returns HTTPResponse.StatusCode func (r V1GetAMigrationResponse) StatusCode() int { if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode + 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 0 + return "" } type V1PatchAMigrationResponse struct { @@ -14736,6 +15704,14 @@ func (r V1PatchAMigrationResponse) StatusCode() int { 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 @@ -14758,6 +15734,14 @@ func (r V1GetDatabaseOpenapiResponse) StatusCode() int { 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 @@ -14780,6 +15764,14 @@ func (r V1UpdateDatabasePasswordResponse) StatusCode() int { 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 @@ -14801,6 +15793,14 @@ func (r V1RunAQueryResponse) StatusCode() int { 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 @@ -14822,6 +15822,14 @@ func (r V1ReadOnlyQueryResponse) StatusCode() int { 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 @@ -14843,6 +15851,14 @@ func (r V1EnableDatabaseWebhookResponse) StatusCode() int { 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 @@ -14865,6 +15881,14 @@ func (r V1ListAllFunctionsResponse) StatusCode() int { 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 @@ -14887,6 +15911,14 @@ func (r V1CreateAFunctionResponse) StatusCode() int { 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 @@ -14909,6 +15941,14 @@ func (r V1BulkUpdateFunctionsResponse) StatusCode() int { 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 @@ -14931,6 +15971,14 @@ func (r V1DeployAFunctionResponse) StatusCode() int { 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 @@ -14952,6 +16000,14 @@ func (r V1DeleteAFunctionResponse) StatusCode() int { 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 @@ -14974,6 +16030,14 @@ func (r V1GetAFunctionResponse) StatusCode() int { 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 @@ -14996,6 +16060,14 @@ func (r V1UpdateAFunctionResponse) StatusCode() int { 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 @@ -15018,6 +16090,14 @@ func (r V1GetAFunctionBodyResponse) StatusCode() int { 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 @@ -15040,6 +16120,14 @@ func (r V1GetServicesHealthResponse) StatusCode() int { 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 @@ -15062,6 +16150,14 @@ func (r V1GetJitAccessConfigResponse) StatusCode() int { 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 @@ -15084,6 +16180,14 @@ func (r V1UpdateJitAccessConfigResponse) StatusCode() int { 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 @@ -15105,6 +16209,14 @@ func (r V1DeleteNetworkBansResponse) StatusCode() int { 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 @@ -15127,6 +16239,14 @@ func (r V1ListAllNetworkBansResponse) StatusCode() int { 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 @@ -15149,6 +16269,14 @@ func (r V1ListAllNetworkBansEnrichedResponse) StatusCode() int { 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 @@ -15171,6 +16299,14 @@ func (r V1GetNetworkRestrictionsResponse) StatusCode() int { 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 @@ -15193,6 +16329,14 @@ func (r V1PatchNetworkRestrictionsResponse) StatusCode() int { 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 @@ -15215,6 +16359,14 @@ func (r V1UpdateNetworkRestrictionsResponse) StatusCode() int { 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 @@ -15236,6 +16388,14 @@ func (r V1PauseAProjectResponse) StatusCode() int { 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 @@ -15258,6 +16418,14 @@ func (r V1GetPgsodiumConfigResponse) StatusCode() int { 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 @@ -15280,6 +16448,14 @@ func (r V1UpdatePgsodiumConfigResponse) StatusCode() int { 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 @@ -15302,6 +16478,14 @@ func (r V1GetPostgrestServiceConfigResponse) StatusCode() int { 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 @@ -15324,6 +16508,14 @@ func (r V1UpdatePostgrestServiceConfigResponse) StatusCode() int { 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 @@ -15345,9 +16537,18 @@ func (r V1RemoveAReadReplicaResponse) StatusCode() int { 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 @@ -15366,6 +16567,14 @@ func (r V1SetupAReadReplicaResponse) StatusCode() int { 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 @@ -15388,6 +16597,14 @@ func (r V1GetReadonlyModeStatusResponse) StatusCode() int { 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 @@ -15409,6 +16626,14 @@ func (r V1DisableReadonlyModeTemporarilyResponse) StatusCode() int { 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 @@ -15430,6 +16655,14 @@ func (r V1RestartAProjectResponse) StatusCode() int { 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 @@ -15452,6 +16685,14 @@ func (r V1ListAvailableRestoreVersionsResponse) StatusCode() int { 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 @@ -15473,6 +16714,14 @@ func (r V1RestoreAProjectResponse) StatusCode() int { 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 @@ -15494,6 +16743,14 @@ func (r V1CancelAProjectRestorationResponse) StatusCode() int { 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 @@ -15515,6 +16772,14 @@ func (r V1BulkDeleteSecretsResponse) StatusCode() int { 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 @@ -15537,6 +16802,14 @@ func (r V1ListAllSecretsResponse) StatusCode() int { 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 @@ -15558,6 +16831,14 @@ func (r V1BulkCreateSecretsResponse) StatusCode() int { 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 @@ -15580,6 +16861,14 @@ func (r V1GetSslEnforcementConfigResponse) StatusCode() int { 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 @@ -15602,6 +16891,14 @@ func (r V1UpdateSslEnforcementConfigResponse) StatusCode() int { 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 @@ -15624,6 +16921,14 @@ func (r V1ListAllBucketsResponse) StatusCode() int { 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 @@ -15646,6 +16951,14 @@ func (r V1GenerateTypescriptTypesResponse) StatusCode() int { 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 @@ -15668,6 +16981,14 @@ func (r V1UpgradePostgresVersionResponse) StatusCode() int { 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 @@ -15690,6 +17011,14 @@ func (r V1GetPostgresUpgradeEligibilityResponse) StatusCode() int { 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 @@ -15712,6 +17041,14 @@ func (r V1GetPostgresUpgradeStatusResponse) StatusCode() int { 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 @@ -15733,10 +17070,19 @@ func (r V1DeactivateVanitySubdomainConfigResponse) StatusCode() int { 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 *VanitySubdomainConfigResponse + JSON400 *PlanGateErrorBody } // Status returns HTTPResponse.Status @@ -15755,10 +17101,19 @@ func (r V1GetVanitySubdomainConfigResponse) StatusCode() int { 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 *ActivateVanitySubdomainResponse + JSON400 *PlanGateErrorBody } // Status returns HTTPResponse.Status @@ -15777,10 +17132,19 @@ func (r V1ActivateVanitySubdomainConfigResponse) StatusCode() int { 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 *SubdomainAvailabilityResponse + JSON400 *PlanGateErrorBody } // Status returns HTTPResponse.Status @@ -15799,6 +17163,14 @@ func (r V1CheckVanitySubdomainAvailabilityResponse) StatusCode() int { 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 @@ -15821,6 +17193,14 @@ func (r V1ListAllSnippetsResponse) StatusCode() int { 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 @@ -15843,6 +17223,14 @@ func (r V1GetASnippetResponse) StatusCode() int { 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...) @@ -16256,6 +17644,15 @@ func (c *ClientWithResponses) V1GetProjectLogsAllWithResponse(ctx context.Contex 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...) @@ -18730,6 +20127,22 @@ func ParseV1GetProjectLogsAllResponse(rsp *http.Response) (*V1GetProjectLogsAllR 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) @@ -20279,6 +21692,13 @@ func ParseV1GetBackupScheduleResponse(rsp *http.Response) (*V1GetBackupScheduleR } 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 @@ -20305,6 +21725,13 @@ func ParseV1UpdateBackupScheduleResponse(rsp *http.Response) (*V1UpdateBackupSch } 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 @@ -21327,6 +22754,16 @@ func ParseV1SetupAReadReplicaResponse(rsp *http.Response) (*V1SetupAReadReplicaR 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 } @@ -21723,6 +23160,13 @@ func ParseV1GetVanitySubdomainConfigResponse(rsp *http.Response) (*V1GetVanitySu } 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 @@ -21749,6 +23193,13 @@ func ParseV1ActivateVanitySubdomainConfigResponse(rsp *http.Response) (*V1Activa } 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 @@ -21775,6 +23226,13 @@ func ParseV1CheckVanitySubdomainAvailabilityResponse(rsp *http.Response) (*V1Che } 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 diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index 743af52366..315c1560c9 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -1,6 +1,6 @@ // Package api provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. package api import ( @@ -14,9 +14,7 @@ import ( ) const ( - BearerScopes = "bearer.Scopes" - Fga_permissionsScopes = "fga_permissions.Scopes" - Oauth2Scopes = "oauth2.Scopes" + BearerScopes bearerContextKey = "bearer.Scopes" ) // Defines values for ActionRunResponseRunStepsName. @@ -30,6 +28,28 @@ const ( ActionRunResponseRunStepsNameSeed ActionRunResponseRunStepsName = "seed" ) +// Valid indicates whether the value is a known member of the ActionRunResponseRunStepsName enum. +func (e ActionRunResponseRunStepsName) Valid() bool { + switch e { + case ActionRunResponseRunStepsNameClone: + return true + case ActionRunResponseRunStepsNameConfigure: + return true + case ActionRunResponseRunStepsNameDeploy: + return true + case ActionRunResponseRunStepsNameHealth: + return true + case ActionRunResponseRunStepsNameMigrate: + return true + case ActionRunResponseRunStepsNamePull: + return true + case ActionRunResponseRunStepsNameSeed: + return true + default: + return false + } +} + // Defines values for ActionRunResponseRunStepsStatus. const ( ActionRunResponseRunStepsStatusCREATED ActionRunResponseRunStepsStatus = "CREATED" @@ -41,6 +61,28 @@ const ( ActionRunResponseRunStepsStatusRUNNING ActionRunResponseRunStepsStatus = "RUNNING" ) +// Valid indicates whether the value is a known member of the ActionRunResponseRunStepsStatus enum. +func (e ActionRunResponseRunStepsStatus) Valid() bool { + switch e { + case ActionRunResponseRunStepsStatusCREATED: + return true + case ActionRunResponseRunStepsStatusDEAD: + return true + case ActionRunResponseRunStepsStatusEXITED: + return true + case ActionRunResponseRunStepsStatusPAUSED: + return true + case ActionRunResponseRunStepsStatusREMOVING: + return true + case ActionRunResponseRunStepsStatusRESTARTING: + return true + case ActionRunResponseRunStepsStatusRUNNING: + return true + default: + return false + } +} + // Defines values for ApiKeyResponseType. const ( ApiKeyResponseTypeLegacy ApiKeyResponseType = "legacy" @@ -48,6 +90,20 @@ const ( ApiKeyResponseTypeSecret ApiKeyResponseType = "secret" ) +// Valid indicates whether the value is a known member of the ApiKeyResponseType enum. +func (e ApiKeyResponseType) Valid() bool { + switch e { + case ApiKeyResponseTypeLegacy: + return true + case ApiKeyResponseTypePublishable: + return true + case ApiKeyResponseTypeSecret: + return true + default: + return false + } +} + // Defines values for ApplyProjectAddonBodyAddonType. const ( ApplyProjectAddonBodyAddonTypeAuthMfaPhone ApplyProjectAddonBodyAddonType = "auth_mfa_phone" @@ -60,6 +116,30 @@ const ( 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" @@ -82,11 +162,65 @@ const ( 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" @@ -94,17 +228,53 @@ const ( 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 AuthConfigResponseDbMaxPoolSizeUnit. const ( AuthConfigResponseDbMaxPoolSizeUnitConnections AuthConfigResponseDbMaxPoolSizeUnit = "connections" AuthConfigResponseDbMaxPoolSizeUnitPercent AuthConfigResponseDbMaxPoolSizeUnit = "percent" ) +// Valid indicates whether the value is a known member of the AuthConfigResponseDbMaxPoolSizeUnit enum. +func (e AuthConfigResponseDbMaxPoolSizeUnit) Valid() bool { + switch e { + case AuthConfigResponseDbMaxPoolSizeUnitConnections: + return true + case AuthConfigResponseDbMaxPoolSizeUnitPercent: + return true + default: + return false + } +} + // Defines values for AuthConfigResponsePasswordRequiredCharacters. const ( AuthConfigResponsePasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 AuthConfigResponsePasswordRequiredCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789" @@ -113,12 +283,40 @@ const ( AuthConfigResponsePasswordRequiredCharactersEmpty AuthConfigResponsePasswordRequiredCharacters = "" ) +// Valid indicates whether the value is a known member of the AuthConfigResponsePasswordRequiredCharacters enum. +func (e AuthConfigResponsePasswordRequiredCharacters) Valid() bool { + switch e { + case AuthConfigResponsePasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789: + return true + case AuthConfigResponsePasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567891: + return true + case AuthConfigResponsePasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567892: + return true + case AuthConfigResponsePasswordRequiredCharactersEmpty: + return true + default: + return false + } +} + // Defines values for AuthConfigResponseSecurityCaptchaProvider. const ( AuthConfigResponseSecurityCaptchaProviderHcaptcha AuthConfigResponseSecurityCaptchaProvider = "hcaptcha" AuthConfigResponseSecurityCaptchaProviderTurnstile AuthConfigResponseSecurityCaptchaProvider = "turnstile" ) +// Valid indicates whether the value is a known member of the AuthConfigResponseSecurityCaptchaProvider enum. +func (e AuthConfigResponseSecurityCaptchaProvider) Valid() bool { + switch e { + case AuthConfigResponseSecurityCaptchaProviderHcaptcha: + return true + case AuthConfigResponseSecurityCaptchaProviderTurnstile: + return true + default: + return false + } +} + // Defines values for AuthConfigResponseSmsProvider. const ( AuthConfigResponseSmsProviderMessagebird AuthConfigResponseSmsProvider = "messagebird" @@ -128,11 +326,39 @@ const ( AuthConfigResponseSmsProviderVonage AuthConfigResponseSmsProvider = "vonage" ) +// Valid indicates whether the value is a known member of the AuthConfigResponseSmsProvider enum. +func (e AuthConfigResponseSmsProvider) Valid() bool { + switch e { + case AuthConfigResponseSmsProviderMessagebird: + return true + case AuthConfigResponseSmsProviderTextlocal: + return true + case AuthConfigResponseSmsProviderTwilio: + return true + case AuthConfigResponseSmsProviderTwilioVerify: + return true + case AuthConfigResponseSmsProviderVonage: + return true + default: + return false + } +} + // Defines values for BranchDeleteResponseMessage. const ( BranchDeleteResponseMessageOk BranchDeleteResponseMessage = "ok" ) +// Valid indicates whether the value is a known member of the BranchDeleteResponseMessage enum. +func (e BranchDeleteResponseMessage) Valid() bool { + switch e { + case BranchDeleteResponseMessageOk: + return true + default: + return false + } +} + // Defines values for BranchDetailResponseStatus. const ( BranchDetailResponseStatusACTIVEHEALTHY BranchDetailResponseStatus = "ACTIVE_HEALTHY" @@ -152,6 +378,44 @@ const ( BranchDetailResponseStatusUPGRADING BranchDetailResponseStatus = "UPGRADING" ) +// Valid indicates whether the value is a known member of the BranchDetailResponseStatus enum. +func (e BranchDetailResponseStatus) Valid() bool { + switch e { + case BranchDetailResponseStatusACTIVEHEALTHY: + return true + case BranchDetailResponseStatusACTIVEUNHEALTHY: + return true + case BranchDetailResponseStatusCOMINGUP: + return true + case BranchDetailResponseStatusGOINGDOWN: + return true + case BranchDetailResponseStatusINACTIVE: + return true + case BranchDetailResponseStatusINITFAILED: + return true + case BranchDetailResponseStatusPAUSEFAILED: + return true + case BranchDetailResponseStatusPAUSING: + return true + case BranchDetailResponseStatusREMOVED: + return true + case BranchDetailResponseStatusRESIZING: + return true + case BranchDetailResponseStatusRESTARTING: + return true + case BranchDetailResponseStatusRESTOREFAILED: + return true + case BranchDetailResponseStatusRESTORING: + return true + case BranchDetailResponseStatusUNKNOWN: + return true + case BranchDetailResponseStatusUPGRADING: + return true + default: + return false + } +} + // Defines values for BranchResponsePreviewProjectStatus. const ( BranchResponsePreviewProjectStatusACTIVEHEALTHY BranchResponsePreviewProjectStatus = "ACTIVE_HEALTHY" @@ -171,6 +435,44 @@ const ( BranchResponsePreviewProjectStatusUPGRADING BranchResponsePreviewProjectStatus = "UPGRADING" ) +// Valid indicates whether the value is a known member of the BranchResponsePreviewProjectStatus enum. +func (e BranchResponsePreviewProjectStatus) Valid() bool { + switch e { + case BranchResponsePreviewProjectStatusACTIVEHEALTHY: + return true + case BranchResponsePreviewProjectStatusACTIVEUNHEALTHY: + return true + case BranchResponsePreviewProjectStatusCOMINGUP: + return true + case BranchResponsePreviewProjectStatusGOINGDOWN: + return true + case BranchResponsePreviewProjectStatusINACTIVE: + return true + case BranchResponsePreviewProjectStatusINITFAILED: + return true + case BranchResponsePreviewProjectStatusPAUSEFAILED: + return true + case BranchResponsePreviewProjectStatusPAUSING: + return true + case BranchResponsePreviewProjectStatusREMOVED: + return true + case BranchResponsePreviewProjectStatusRESIZING: + return true + case BranchResponsePreviewProjectStatusRESTARTING: + return true + case BranchResponsePreviewProjectStatusRESTOREFAILED: + return true + case BranchResponsePreviewProjectStatusRESTORING: + return true + case BranchResponsePreviewProjectStatusUNKNOWN: + return true + case BranchResponsePreviewProjectStatusUPGRADING: + return true + default: + return false + } +} + // Defines values for BranchResponseStatus. const ( BranchResponseStatusCREATINGPROJECT BranchResponseStatus = "CREATING_PROJECT" @@ -181,16 +483,56 @@ const ( BranchResponseStatusRUNNINGMIGRATIONS BranchResponseStatus = "RUNNING_MIGRATIONS" ) +// Valid indicates whether the value is a known member of the BranchResponseStatus enum. +func (e BranchResponseStatus) Valid() bool { + switch e { + case BranchResponseStatusCREATINGPROJECT: + return true + case BranchResponseStatusFUNCTIONSDEPLOYED: + return true + case BranchResponseStatusFUNCTIONSFAILED: + return true + case BranchResponseStatusMIGRATIONSFAILED: + return true + case BranchResponseStatusMIGRATIONSPASSED: + return true + case BranchResponseStatusRUNNINGMIGRATIONS: + return true + default: + return false + } +} + // Defines values for BranchRestoreResponseMessage. const ( BranchRestorationInitiated BranchRestoreResponseMessage = "Branch restoration initiated" ) +// Valid indicates whether the value is a known member of the BranchRestoreResponseMessage enum. +func (e BranchRestoreResponseMessage) Valid() bool { + switch e { + case BranchRestorationInitiated: + return true + default: + return false + } +} + // Defines values for BranchUpdateResponseMessage. const ( BranchUpdateResponseMessageOk BranchUpdateResponseMessage = "ok" ) +// Valid indicates whether the value is a known member of the BranchUpdateResponseMessage enum. +func (e BranchUpdateResponseMessage) Valid() bool { + switch e { + case BranchUpdateResponseMessageOk: + return true + default: + return false + } +} + // Defines values for BulkUpdateFunctionBodyStatus. const ( BulkUpdateFunctionBodyStatusACTIVE BulkUpdateFunctionBodyStatus = "ACTIVE" @@ -198,6 +540,20 @@ const ( 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 BulkUpdateFunctionResponseFunctionsStatus. const ( BulkUpdateFunctionResponseFunctionsStatusACTIVE BulkUpdateFunctionResponseFunctionsStatus = "ACTIVE" @@ -205,12 +561,38 @@ const ( BulkUpdateFunctionResponseFunctionsStatusTHROTTLED BulkUpdateFunctionResponseFunctionsStatus = "THROTTLED" ) +// Valid indicates whether the value is a known member of the BulkUpdateFunctionResponseFunctionsStatus enum. +func (e BulkUpdateFunctionResponseFunctionsStatus) Valid() bool { + switch e { + case BulkUpdateFunctionResponseFunctionsStatusACTIVE: + return true + case BulkUpdateFunctionResponseFunctionsStatusREMOVED: + return true + case BulkUpdateFunctionResponseFunctionsStatusTHROTTLED: + return true + default: + return false + } +} + // Defines values for CreateApiKeyBodyType. const ( CreateApiKeyBodyTypePublishable CreateApiKeyBodyType = "publishable" CreateApiKeyBodyTypeSecret CreateApiKeyBodyType = "secret" ) +// Valid indicates whether the value is a known member of the CreateApiKeyBodyType enum. +func (e CreateApiKeyBodyType) Valid() bool { + switch e { + case CreateApiKeyBodyTypePublishable: + return true + case CreateApiKeyBodyTypeSecret: + return true + default: + return false + } +} + // Defines values for CreateBranchBodyDesiredInstanceSize. const ( CreateBranchBodyDesiredInstanceSizeLarge CreateBranchBodyDesiredInstanceSize = "large" @@ -235,6 +617,54 @@ const ( 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" @@ -242,6 +672,20 @@ const ( 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" @@ -252,6 +696,26 @@ const ( 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" @@ -260,11 +724,37 @@ const ( 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" @@ -273,131 +763,397 @@ const ( 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 DatabaseUpgradeStatusResponseDatabaseUpgradeStatusError. const ( N1UpgradedInstanceLaunchFailed DatabaseUpgradeStatusResponseDatabaseUpgradeStatusError = "1_upgraded_instance_launch_failed" @@ -411,6 +1167,32 @@ const ( N9PostPhysicalBackupFailed DatabaseUpgradeStatusResponseDatabaseUpgradeStatusError = "9_post_physical_backup_failed" ) +// Valid indicates whether the value is a known member of the DatabaseUpgradeStatusResponseDatabaseUpgradeStatusError enum. +func (e DatabaseUpgradeStatusResponseDatabaseUpgradeStatusError) 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 DatabaseUpgradeStatusResponseDatabaseUpgradeStatusProgress. const ( N0Requested DatabaseUpgradeStatusResponseDatabaseUpgradeStatusProgress = "0_requested" @@ -426,11 +1208,51 @@ const ( N9CompletedUpgrade DatabaseUpgradeStatusResponseDatabaseUpgradeStatusProgress = "9_completed_upgrade" ) +// Valid indicates whether the value is a known member of the DatabaseUpgradeStatusResponseDatabaseUpgradeStatusProgress enum. +func (e DatabaseUpgradeStatusResponseDatabaseUpgradeStatusProgress) 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 DeleteRolesResponseMessage. const ( DeleteRolesResponseMessageOk DeleteRolesResponseMessage = "ok" ) +// Valid indicates whether the value is a known member of the DeleteRolesResponseMessage enum. +func (e DeleteRolesResponseMessage) Valid() bool { + switch e { + case DeleteRolesResponseMessageOk: + return true + default: + return false + } +} + // Defines values for DeployFunctionResponseStatus. const ( DeployFunctionResponseStatusACTIVE DeployFunctionResponseStatus = "ACTIVE" @@ -438,26 +1260,80 @@ const ( DeployFunctionResponseStatusTHROTTLED DeployFunctionResponseStatus = "THROTTLED" ) +// Valid indicates whether the value is a known member of the DeployFunctionResponseStatus enum. +func (e DeployFunctionResponseStatus) Valid() bool { + switch e { + case DeployFunctionResponseStatusACTIVE: + return true + case DeployFunctionResponseStatusREMOVED: + return true + case DeployFunctionResponseStatusTHROTTLED: + 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 DiskResponseAttributes0Type. const ( DiskResponseAttributes0TypeGp3 DiskResponseAttributes0Type = "gp3" ) +// Valid indicates whether the value is a known member of the DiskResponseAttributes0Type enum. +func (e DiskResponseAttributes0Type) Valid() bool { + switch e { + case DiskResponseAttributes0TypeGp3: + return true + default: + return false + } +} + // Defines values for DiskResponseAttributes1Type. const ( DiskResponseAttributes1TypeIo2 DiskResponseAttributes1Type = "io2" ) +// Valid indicates whether the value is a known member of the DiskResponseAttributes1Type enum. +func (e DiskResponseAttributes1Type) Valid() bool { + switch e { + case DiskResponseAttributes1TypeIo2: + return true + default: + return false + } +} + // Defines values for FunctionResponseStatus. const ( FunctionResponseStatusACTIVE FunctionResponseStatus = "ACTIVE" @@ -465,6 +1341,20 @@ const ( FunctionResponseStatusTHROTTLED FunctionResponseStatus = "THROTTLED" ) +// Valid indicates whether the value is a known member of the FunctionResponseStatus enum. +func (e FunctionResponseStatus) Valid() bool { + switch e { + case FunctionResponseStatusACTIVE: + return true + case FunctionResponseStatusREMOVED: + return true + case FunctionResponseStatusTHROTTLED: + return true + default: + return false + } +} + // Defines values for FunctionSlugResponseStatus. const ( FunctionSlugResponseStatusACTIVE FunctionSlugResponseStatus = "ACTIVE" @@ -472,6 +1362,20 @@ const ( FunctionSlugResponseStatusTHROTTLED FunctionSlugResponseStatus = "THROTTLED" ) +// Valid indicates whether the value is a known member of the FunctionSlugResponseStatus enum. +func (e FunctionSlugResponseStatus) Valid() bool { + switch e { + case FunctionSlugResponseStatusACTIVE: + return true + case FunctionSlugResponseStatusREMOVED: + return true + case FunctionSlugResponseStatusTHROTTLED: + return true + default: + return false + } +} + // Defines values for GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngine. const ( GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngineN13 GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngine = "13" @@ -481,6 +1385,24 @@ const ( GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngineN17Oriole GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngine = "17-oriole" ) +// Valid indicates whether the value is a known member of the GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngine enum. +func (e GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngine) Valid() bool { + switch e { + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngineN13: + return true + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngineN14: + return true + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngineN15: + return true + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngineN17: + return true + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsPostgresEngineN17Oriole: + return true + default: + return false + } +} + // Defines values for GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannel. const ( GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannelAlpha GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannel = "alpha" @@ -491,29 +1413,98 @@ const ( GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannelWithdrawn GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannel = "withdrawn" ) +// Valid indicates whether the value is a known member of the GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannel enum. +func (e GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannel) Valid() bool { + switch e { + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannelAlpha: + return true + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannelBeta: + return true + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannelGa: + return true + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannelInternal: + return true + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannelPreview: + return true + case GetProjectAvailableRestoreVersionsResponseAvailableVersionsReleaseChannelWithdrawn: + 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 JitStateResponse0State. const ( JitStateResponse0StateDisabled JitStateResponse0State = "disabled" JitStateResponse0StateEnabled JitStateResponse0State = "enabled" ) +// Valid indicates whether the value is a known member of the JitStateResponse0State enum. +func (e JitStateResponse0State) Valid() bool { + switch e { + case JitStateResponse0StateDisabled: + return true + case JitStateResponse0StateEnabled: + return true + default: + return false + } +} + // Defines values for JitStateResponse1State. const ( Unavailable JitStateResponse1State = "unavailable" ) +// Valid indicates whether the value is a known member of the JitStateResponse1State enum. +func (e JitStateResponse1State) Valid() bool { + switch e { + case Unavailable: + return true + default: + return false + } +} + // Defines values for JitStateResponse1UnavailableReason. const ( PostgresUpgradeRequired JitStateResponse1UnavailableReason = "postgres_upgrade_required" + SslEnforcementRequired JitStateResponse1UnavailableReason = "ssl_enforcement_required" TemporarilyUnavailable JitStateResponse1UnavailableReason = "temporarily_unavailable" ) +// Valid indicates whether the value is a known member of the JitStateResponse1UnavailableReason enum. +func (e JitStateResponse1UnavailableReason) Valid() bool { + switch e { + case PostgresUpgradeRequired: + return true + case SslEnforcementRequired: + return true + case TemporarilyUnavailable: + return true + default: + return false + } +} + // Defines values for ListActionRunResponseRunStepsName. const ( ListActionRunResponseRunStepsNameClone ListActionRunResponseRunStepsName = "clone" @@ -525,6 +1516,28 @@ const ( ListActionRunResponseRunStepsNameSeed ListActionRunResponseRunStepsName = "seed" ) +// Valid indicates whether the value is a known member of the ListActionRunResponseRunStepsName enum. +func (e ListActionRunResponseRunStepsName) Valid() bool { + switch e { + case ListActionRunResponseRunStepsNameClone: + return true + case ListActionRunResponseRunStepsNameConfigure: + return true + case ListActionRunResponseRunStepsNameDeploy: + return true + case ListActionRunResponseRunStepsNameHealth: + return true + case ListActionRunResponseRunStepsNameMigrate: + return true + case ListActionRunResponseRunStepsNamePull: + return true + case ListActionRunResponseRunStepsNameSeed: + return true + default: + return false + } +} + // Defines values for ListActionRunResponseRunStepsStatus. const ( ListActionRunResponseRunStepsStatusCREATED ListActionRunResponseRunStepsStatus = "CREATED" @@ -536,6 +1549,28 @@ const ( ListActionRunResponseRunStepsStatusRUNNING ListActionRunResponseRunStepsStatus = "RUNNING" ) +// Valid indicates whether the value is a known member of the ListActionRunResponseRunStepsStatus enum. +func (e ListActionRunResponseRunStepsStatus) Valid() bool { + switch e { + case ListActionRunResponseRunStepsStatusCREATED: + return true + case ListActionRunResponseRunStepsStatusDEAD: + return true + case ListActionRunResponseRunStepsStatusEXITED: + return true + case ListActionRunResponseRunStepsStatusPAUSED: + return true + case ListActionRunResponseRunStepsStatusREMOVING: + return true + case ListActionRunResponseRunStepsStatusRESTARTING: + return true + case ListActionRunResponseRunStepsStatusRUNNING: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsType. const ( ListProjectAddonsResponseAvailableAddonsTypeAuthMfaPhone ListProjectAddonsResponseAvailableAddonsType = "auth_mfa_phone" @@ -548,6 +1583,30 @@ const ( ListProjectAddonsResponseAvailableAddonsTypePitr ListProjectAddonsResponseAvailableAddonsType = "pitr" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsType enum. +func (e ListProjectAddonsResponseAvailableAddonsType) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsTypeAuthMfaPhone: + return true + case ListProjectAddonsResponseAvailableAddonsTypeAuthMfaWebAuthn: + return true + case ListProjectAddonsResponseAvailableAddonsTypeComputeInstance: + return true + case ListProjectAddonsResponseAvailableAddonsTypeCustomDomain: + return true + case ListProjectAddonsResponseAvailableAddonsTypeEtlPipeline: + return true + case ListProjectAddonsResponseAvailableAddonsTypeIpv4: + return true + case ListProjectAddonsResponseAvailableAddonsTypeLogDrain: + return true + case ListProjectAddonsResponseAvailableAddonsTypePitr: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsId0. const ( ListProjectAddonsResponseAvailableAddonsVariantsId0Ci12xlarge ListProjectAddonsResponseAvailableAddonsVariantsId0 = "ci_12xlarge" @@ -570,11 +1629,65 @@ const ( ListProjectAddonsResponseAvailableAddonsVariantsId0CiXlarge ListProjectAddonsResponseAvailableAddonsVariantsId0 = "ci_xlarge" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsId0 enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsId0) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci12xlarge: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci16xlarge: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci24xlarge: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci24xlargeHighMemory: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci24xlargeOptimizedCpu: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci24xlargeOptimizedMemory: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci2xlarge: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci48xlarge: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci48xlargeHighMemory: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci48xlargeOptimizedCpu: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci48xlargeOptimizedMemory: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci4xlarge: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0Ci8xlarge: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0CiLarge: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0CiMedium: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0CiMicro: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0CiSmall: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId0CiXlarge: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsId1. const ( ListProjectAddonsResponseAvailableAddonsVariantsId1CdDefault ListProjectAddonsResponseAvailableAddonsVariantsId1 = "cd_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsId1 enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsId1) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsId1CdDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsId2. const ( ListProjectAddonsResponseAvailableAddonsVariantsId2Pitr14 ListProjectAddonsResponseAvailableAddonsVariantsId2 = "pitr_14" @@ -582,43 +1695,131 @@ const ( ListProjectAddonsResponseAvailableAddonsVariantsId2Pitr7 ListProjectAddonsResponseAvailableAddonsVariantsId2 = "pitr_7" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsId2 enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsId2) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsId2Pitr14: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId2Pitr28: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsId2Pitr7: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsId3. const ( ListProjectAddonsResponseAvailableAddonsVariantsId3Ipv4Default ListProjectAddonsResponseAvailableAddonsVariantsId3 = "ipv4_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsId3 enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsId3) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsId3Ipv4Default: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsId4. const ( ListProjectAddonsResponseAvailableAddonsVariantsId4AuthMfaPhoneDefault ListProjectAddonsResponseAvailableAddonsVariantsId4 = "auth_mfa_phone_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsId4 enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsId4) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsId4AuthMfaPhoneDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsId5. const ( ListProjectAddonsResponseAvailableAddonsVariantsId5AuthMfaWebAuthnDefault ListProjectAddonsResponseAvailableAddonsVariantsId5 = "auth_mfa_web_authn_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsId5 enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsId5) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsId5AuthMfaWebAuthnDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsId6. const ( ListProjectAddonsResponseAvailableAddonsVariantsId6LogDrainDefault ListProjectAddonsResponseAvailableAddonsVariantsId6 = "log_drain_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsId6 enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsId6) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsId6LogDrainDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsId7. const ( ListProjectAddonsResponseAvailableAddonsVariantsId7EtlPipelineDefault ListProjectAddonsResponseAvailableAddonsVariantsId7 = "etl_pipeline_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsId7 enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsId7) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsId7EtlPipelineDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsPriceInterval. const ( ListProjectAddonsResponseAvailableAddonsVariantsPriceIntervalHourly ListProjectAddonsResponseAvailableAddonsVariantsPriceInterval = "hourly" ListProjectAddonsResponseAvailableAddonsVariantsPriceIntervalMonthly ListProjectAddonsResponseAvailableAddonsVariantsPriceInterval = "monthly" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsPriceInterval enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsPriceInterval) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsPriceIntervalHourly: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsPriceIntervalMonthly: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsPriceType. const ( ListProjectAddonsResponseAvailableAddonsVariantsPriceTypeFixed ListProjectAddonsResponseAvailableAddonsVariantsPriceType = "fixed" ListProjectAddonsResponseAvailableAddonsVariantsPriceTypeUsage ListProjectAddonsResponseAvailableAddonsVariantsPriceType = "usage" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseAvailableAddonsVariantsPriceType enum. +func (e ListProjectAddonsResponseAvailableAddonsVariantsPriceType) Valid() bool { + switch e { + case ListProjectAddonsResponseAvailableAddonsVariantsPriceTypeFixed: + return true + case ListProjectAddonsResponseAvailableAddonsVariantsPriceTypeUsage: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsType. const ( ListProjectAddonsResponseSelectedAddonsTypeAuthMfaPhone ListProjectAddonsResponseSelectedAddonsType = "auth_mfa_phone" @@ -631,6 +1832,30 @@ const ( ListProjectAddonsResponseSelectedAddonsTypePitr ListProjectAddonsResponseSelectedAddonsType = "pitr" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsType enum. +func (e ListProjectAddonsResponseSelectedAddonsType) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsTypeAuthMfaPhone: + return true + case ListProjectAddonsResponseSelectedAddonsTypeAuthMfaWebAuthn: + return true + case ListProjectAddonsResponseSelectedAddonsTypeComputeInstance: + return true + case ListProjectAddonsResponseSelectedAddonsTypeCustomDomain: + return true + case ListProjectAddonsResponseSelectedAddonsTypeEtlPipeline: + return true + case ListProjectAddonsResponseSelectedAddonsTypeIpv4: + return true + case ListProjectAddonsResponseSelectedAddonsTypeLogDrain: + return true + case ListProjectAddonsResponseSelectedAddonsTypePitr: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantId0. const ( ListProjectAddonsResponseSelectedAddonsVariantId0Ci12xlarge ListProjectAddonsResponseSelectedAddonsVariantId0 = "ci_12xlarge" @@ -653,11 +1878,65 @@ const ( ListProjectAddonsResponseSelectedAddonsVariantId0CiXlarge ListProjectAddonsResponseSelectedAddonsVariantId0 = "ci_xlarge" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantId0 enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantId0) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci12xlarge: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci16xlarge: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci24xlarge: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci24xlargeHighMemory: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci24xlargeOptimizedCpu: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci24xlargeOptimizedMemory: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci2xlarge: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci48xlarge: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci48xlargeHighMemory: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci48xlargeOptimizedCpu: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci48xlargeOptimizedMemory: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci4xlarge: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0Ci8xlarge: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0CiLarge: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0CiMedium: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0CiMicro: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0CiSmall: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId0CiXlarge: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantId1. const ( ListProjectAddonsResponseSelectedAddonsVariantId1CdDefault ListProjectAddonsResponseSelectedAddonsVariantId1 = "cd_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantId1 enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantId1) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantId1CdDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantId2. const ( ListProjectAddonsResponseSelectedAddonsVariantId2Pitr14 ListProjectAddonsResponseSelectedAddonsVariantId2 = "pitr_14" @@ -665,79 +1944,239 @@ const ( ListProjectAddonsResponseSelectedAddonsVariantId2Pitr7 ListProjectAddonsResponseSelectedAddonsVariantId2 = "pitr_7" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantId2 enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantId2) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantId2Pitr14: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId2Pitr28: + return true + case ListProjectAddonsResponseSelectedAddonsVariantId2Pitr7: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantId3. const ( ListProjectAddonsResponseSelectedAddonsVariantId3Ipv4Default ListProjectAddonsResponseSelectedAddonsVariantId3 = "ipv4_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantId3 enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantId3) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantId3Ipv4Default: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantId4. const ( ListProjectAddonsResponseSelectedAddonsVariantId4AuthMfaPhoneDefault ListProjectAddonsResponseSelectedAddonsVariantId4 = "auth_mfa_phone_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantId4 enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantId4) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantId4AuthMfaPhoneDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantId5. const ( ListProjectAddonsResponseSelectedAddonsVariantId5AuthMfaWebAuthnDefault ListProjectAddonsResponseSelectedAddonsVariantId5 = "auth_mfa_web_authn_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantId5 enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantId5) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantId5AuthMfaWebAuthnDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantId6. const ( ListProjectAddonsResponseSelectedAddonsVariantId6LogDrainDefault ListProjectAddonsResponseSelectedAddonsVariantId6 = "log_drain_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantId6 enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantId6) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantId6LogDrainDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantId7. const ( ListProjectAddonsResponseSelectedAddonsVariantId7EtlPipelineDefault ListProjectAddonsResponseSelectedAddonsVariantId7 = "etl_pipeline_default" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantId7 enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantId7) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantId7EtlPipelineDefault: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantPriceInterval. const ( ListProjectAddonsResponseSelectedAddonsVariantPriceIntervalHourly ListProjectAddonsResponseSelectedAddonsVariantPriceInterval = "hourly" ListProjectAddonsResponseSelectedAddonsVariantPriceIntervalMonthly ListProjectAddonsResponseSelectedAddonsVariantPriceInterval = "monthly" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantPriceInterval enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantPriceInterval) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantPriceIntervalHourly: + return true + case ListProjectAddonsResponseSelectedAddonsVariantPriceIntervalMonthly: + return true + default: + return false + } +} + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantPriceType. const ( ListProjectAddonsResponseSelectedAddonsVariantPriceTypeFixed ListProjectAddonsResponseSelectedAddonsVariantPriceType = "fixed" ListProjectAddonsResponseSelectedAddonsVariantPriceTypeUsage ListProjectAddonsResponseSelectedAddonsVariantPriceType = "usage" ) +// Valid indicates whether the value is a known member of the ListProjectAddonsResponseSelectedAddonsVariantPriceType enum. +func (e ListProjectAddonsResponseSelectedAddonsVariantPriceType) Valid() bool { + switch e { + case ListProjectAddonsResponseSelectedAddonsVariantPriceTypeFixed: + return true + case ListProjectAddonsResponseSelectedAddonsVariantPriceTypeUsage: + return true + default: + return false + } +} + // Defines values for NetworkRestrictionsResponseEntitlement. const ( NetworkRestrictionsResponseEntitlementAllowed NetworkRestrictionsResponseEntitlement = "allowed" NetworkRestrictionsResponseEntitlementDisallowed NetworkRestrictionsResponseEntitlement = "disallowed" ) +// Valid indicates whether the value is a known member of the NetworkRestrictionsResponseEntitlement enum. +func (e NetworkRestrictionsResponseEntitlement) Valid() bool { + switch e { + case NetworkRestrictionsResponseEntitlementAllowed: + return true + case NetworkRestrictionsResponseEntitlementDisallowed: + return true + default: + return false + } +} + // Defines values for NetworkRestrictionsResponseStatus. const ( NetworkRestrictionsResponseStatusApplied NetworkRestrictionsResponseStatus = "applied" NetworkRestrictionsResponseStatusStored NetworkRestrictionsResponseStatus = "stored" ) +// Valid indicates whether the value is a known member of the NetworkRestrictionsResponseStatus enum. +func (e NetworkRestrictionsResponseStatus) Valid() bool { + switch e { + case NetworkRestrictionsResponseStatusApplied: + return true + case NetworkRestrictionsResponseStatusStored: + return true + default: + return false + } +} + // Defines values for NetworkRestrictionsV2ResponseConfigDbAllowedCidrsType. const ( NetworkRestrictionsV2ResponseConfigDbAllowedCidrsTypeV4 NetworkRestrictionsV2ResponseConfigDbAllowedCidrsType = "v4" NetworkRestrictionsV2ResponseConfigDbAllowedCidrsTypeV6 NetworkRestrictionsV2ResponseConfigDbAllowedCidrsType = "v6" ) +// Valid indicates whether the value is a known member of the NetworkRestrictionsV2ResponseConfigDbAllowedCidrsType enum. +func (e NetworkRestrictionsV2ResponseConfigDbAllowedCidrsType) Valid() bool { + switch e { + case NetworkRestrictionsV2ResponseConfigDbAllowedCidrsTypeV4: + return true + case NetworkRestrictionsV2ResponseConfigDbAllowedCidrsTypeV6: + return true + default: + return false + } +} + // Defines values for NetworkRestrictionsV2ResponseEntitlement. const ( NetworkRestrictionsV2ResponseEntitlementAllowed NetworkRestrictionsV2ResponseEntitlement = "allowed" NetworkRestrictionsV2ResponseEntitlementDisallowed NetworkRestrictionsV2ResponseEntitlement = "disallowed" ) +// Valid indicates whether the value is a known member of the NetworkRestrictionsV2ResponseEntitlement enum. +func (e NetworkRestrictionsV2ResponseEntitlement) Valid() bool { + switch e { + case NetworkRestrictionsV2ResponseEntitlementAllowed: + return true + case NetworkRestrictionsV2ResponseEntitlementDisallowed: + return true + default: + return false + } +} + // Defines values for NetworkRestrictionsV2ResponseOldConfigDbAllowedCidrsType. const ( NetworkRestrictionsV2ResponseOldConfigDbAllowedCidrsTypeV4 NetworkRestrictionsV2ResponseOldConfigDbAllowedCidrsType = "v4" NetworkRestrictionsV2ResponseOldConfigDbAllowedCidrsTypeV6 NetworkRestrictionsV2ResponseOldConfigDbAllowedCidrsType = "v6" ) +// Valid indicates whether the value is a known member of the NetworkRestrictionsV2ResponseOldConfigDbAllowedCidrsType enum. +func (e NetworkRestrictionsV2ResponseOldConfigDbAllowedCidrsType) Valid() bool { + switch e { + case NetworkRestrictionsV2ResponseOldConfigDbAllowedCidrsTypeV4: + return true + case NetworkRestrictionsV2ResponseOldConfigDbAllowedCidrsTypeV6: + return true + default: + return false + } +} + // Defines values for NetworkRestrictionsV2ResponseStatus. const ( NetworkRestrictionsV2ResponseStatusApplied NetworkRestrictionsV2ResponseStatus = "applied" NetworkRestrictionsV2ResponseStatusStored NetworkRestrictionsV2ResponseStatus = "stored" ) +// Valid indicates whether the value is a known member of the NetworkRestrictionsV2ResponseStatus enum. +func (e NetworkRestrictionsV2ResponseStatus) Valid() bool { + switch e { + case NetworkRestrictionsV2ResponseStatusApplied: + return true + case NetworkRestrictionsV2ResponseStatusStored: + return true + default: + return false + } +} + // Defines values for OAuthTokenBodyGrantType. const ( AuthorizationCode OAuthTokenBodyGrantType = "authorization_code" @@ -745,11 +2184,35 @@ const ( 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 OAuthTokenResponseTokenType. const ( Bearer OAuthTokenResponseTokenType = "Bearer" ) +// Valid indicates whether the value is a known member of the OAuthTokenResponseTokenType enum. +func (e OAuthTokenResponseTokenType) Valid() bool { + switch e { + case Bearer: + return true + default: + return false + } +} + // Defines values for OrganizationProjectClaimResponsePreviewSourceSubscriptionPlan. const ( OrganizationProjectClaimResponsePreviewSourceSubscriptionPlanEnterprise OrganizationProjectClaimResponsePreviewSourceSubscriptionPlan = "enterprise" @@ -759,6 +2222,24 @@ const ( OrganizationProjectClaimResponsePreviewSourceSubscriptionPlanTeam OrganizationProjectClaimResponsePreviewSourceSubscriptionPlan = "team" ) +// Valid indicates whether the value is a known member of the OrganizationProjectClaimResponsePreviewSourceSubscriptionPlan enum. +func (e OrganizationProjectClaimResponsePreviewSourceSubscriptionPlan) Valid() bool { + switch e { + case OrganizationProjectClaimResponsePreviewSourceSubscriptionPlanEnterprise: + return true + case OrganizationProjectClaimResponsePreviewSourceSubscriptionPlanFree: + return true + case OrganizationProjectClaimResponsePreviewSourceSubscriptionPlanPlatform: + return true + case OrganizationProjectClaimResponsePreviewSourceSubscriptionPlanPro: + return true + case OrganizationProjectClaimResponsePreviewSourceSubscriptionPlanTeam: + return true + default: + return false + } +} + // Defines values for OrganizationProjectClaimResponsePreviewTargetSubscriptionPlan. const ( OrganizationProjectClaimResponsePreviewTargetSubscriptionPlanEnterprise OrganizationProjectClaimResponsePreviewTargetSubscriptionPlan = "enterprise" @@ -768,12 +2249,42 @@ const ( OrganizationProjectClaimResponsePreviewTargetSubscriptionPlanTeam OrganizationProjectClaimResponsePreviewTargetSubscriptionPlan = "team" ) +// Valid indicates whether the value is a known member of the OrganizationProjectClaimResponsePreviewTargetSubscriptionPlan enum. +func (e OrganizationProjectClaimResponsePreviewTargetSubscriptionPlan) Valid() bool { + switch e { + case OrganizationProjectClaimResponsePreviewTargetSubscriptionPlanEnterprise: + return true + case OrganizationProjectClaimResponsePreviewTargetSubscriptionPlanFree: + return true + case OrganizationProjectClaimResponsePreviewTargetSubscriptionPlanPlatform: + return true + case OrganizationProjectClaimResponsePreviewTargetSubscriptionPlanPro: + return true + case OrganizationProjectClaimResponsePreviewTargetSubscriptionPlanTeam: + return true + default: + return false + } +} + // Defines values for OrganizationProjectsResponseProjectsDatabasesDiskType. const ( Gp3 OrganizationProjectsResponseProjectsDatabasesDiskType = "gp3" Io2 OrganizationProjectsResponseProjectsDatabasesDiskType = "io2" ) +// Valid indicates whether the value is a known member of the OrganizationProjectsResponseProjectsDatabasesDiskType enum. +func (e OrganizationProjectsResponseProjectsDatabasesDiskType) Valid() bool { + switch e { + case Gp3: + return true + case Io2: + return true + default: + return false + } +} + // Defines values for OrganizationProjectsResponseProjectsDatabasesInfraComputeSize. const ( OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeLarge OrganizationProjectsResponseProjectsDatabasesInfraComputeSize = "large" @@ -798,6 +2309,54 @@ const ( OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeXlarge OrganizationProjectsResponseProjectsDatabasesInfraComputeSize = "xlarge" ) +// Valid indicates whether the value is a known member of the OrganizationProjectsResponseProjectsDatabasesInfraComputeSize enum. +func (e OrganizationProjectsResponseProjectsDatabasesInfraComputeSize) Valid() bool { + switch e { + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeLarge: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeMedium: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeMicro: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN12xlarge: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN16xlarge: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN24xlarge: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN24xlargeHighMemory: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN24xlargeOptimizedCpu: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN24xlargeOptimizedMemory: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN2xlarge: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN48xlarge: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN48xlargeHighMemory: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN48xlargeOptimizedCpu: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN48xlargeOptimizedMemory: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN4xlarge: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeN8xlarge: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeNano: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizePico: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeSmall: + return true + case OrganizationProjectsResponseProjectsDatabasesInfraComputeSizeXlarge: + return true + default: + return false + } +} + // Defines values for OrganizationProjectsResponseProjectsDatabasesStatus. const ( OrganizationProjectsResponseProjectsDatabasesStatusACTIVEHEALTHY OrganizationProjectsResponseProjectsDatabasesStatus = "ACTIVE_HEALTHY" @@ -814,12 +2373,56 @@ const ( OrganizationProjectsResponseProjectsDatabasesStatusUNKNOWN OrganizationProjectsResponseProjectsDatabasesStatus = "UNKNOWN" ) +// Valid indicates whether the value is a known member of the OrganizationProjectsResponseProjectsDatabasesStatus enum. +func (e OrganizationProjectsResponseProjectsDatabasesStatus) Valid() bool { + switch e { + case OrganizationProjectsResponseProjectsDatabasesStatusACTIVEHEALTHY: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusACTIVEUNHEALTHY: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusCOMINGUP: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusGOINGDOWN: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusINITFAILED: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusINITREADREPLICA: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusINITREADREPLICAFAILED: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusREMOVED: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusRESIZING: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusRESTARTING: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusRESTORING: + return true + case OrganizationProjectsResponseProjectsDatabasesStatusUNKNOWN: + return true + default: + return false + } +} + // Defines values for OrganizationProjectsResponseProjectsDatabasesType. const ( OrganizationProjectsResponseProjectsDatabasesTypePRIMARY OrganizationProjectsResponseProjectsDatabasesType = "PRIMARY" OrganizationProjectsResponseProjectsDatabasesTypeREADREPLICA OrganizationProjectsResponseProjectsDatabasesType = "READ_REPLICA" ) +// Valid indicates whether the value is a known member of the OrganizationProjectsResponseProjectsDatabasesType enum. +func (e OrganizationProjectsResponseProjectsDatabasesType) Valid() bool { + switch e { + case OrganizationProjectsResponseProjectsDatabasesTypePRIMARY: + return true + case OrganizationProjectsResponseProjectsDatabasesTypeREADREPLICA: + return true + default: + return false + } +} + // Defines values for OrganizationProjectsResponseProjectsStatus. const ( OrganizationProjectsResponseProjectsStatusACTIVEHEALTHY OrganizationProjectsResponseProjectsStatus = "ACTIVE_HEALTHY" @@ -839,6 +2442,59 @@ const ( OrganizationProjectsResponseProjectsStatusUPGRADING OrganizationProjectsResponseProjectsStatus = "UPGRADING" ) +// Valid indicates whether the value is a known member of the OrganizationProjectsResponseProjectsStatus enum. +func (e OrganizationProjectsResponseProjectsStatus) Valid() bool { + switch e { + case OrganizationProjectsResponseProjectsStatusACTIVEHEALTHY: + return true + case OrganizationProjectsResponseProjectsStatusACTIVEUNHEALTHY: + return true + case OrganizationProjectsResponseProjectsStatusCOMINGUP: + return true + case OrganizationProjectsResponseProjectsStatusGOINGDOWN: + return true + case OrganizationProjectsResponseProjectsStatusINACTIVE: + return true + case OrganizationProjectsResponseProjectsStatusINITFAILED: + return true + case OrganizationProjectsResponseProjectsStatusPAUSEFAILED: + return true + case OrganizationProjectsResponseProjectsStatusPAUSING: + return true + case OrganizationProjectsResponseProjectsStatusREMOVED: + return true + case OrganizationProjectsResponseProjectsStatusRESIZING: + return true + case OrganizationProjectsResponseProjectsStatusRESTARTING: + return true + case OrganizationProjectsResponseProjectsStatusRESTOREFAILED: + return true + case OrganizationProjectsResponseProjectsStatusRESTORING: + return true + case OrganizationProjectsResponseProjectsStatusUNKNOWN: + return true + case OrganizationProjectsResponseProjectsStatusUPGRADING: + 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 PostgresConfigResponseSessionReplicationRole. const ( PostgresConfigResponseSessionReplicationRoleLocal PostgresConfigResponseSessionReplicationRole = "local" @@ -846,6 +2502,20 @@ const ( PostgresConfigResponseSessionReplicationRoleReplica PostgresConfigResponseSessionReplicationRole = "replica" ) +// Valid indicates whether the value is a known member of the PostgresConfigResponseSessionReplicationRole enum. +func (e PostgresConfigResponseSessionReplicationRole) Valid() bool { + switch e { + case PostgresConfigResponseSessionReplicationRoleLocal: + return true + case PostgresConfigResponseSessionReplicationRoleOrigin: + return true + case PostgresConfigResponseSessionReplicationRoleReplica: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannel. const ( ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannelAlpha ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannel = "alpha" @@ -856,6 +2526,26 @@ const ( ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannelWithdrawn ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannel = "withdrawn" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannel enum. +func (e ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannel) Valid() bool { + switch e { + case ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannelAlpha: + return true + case ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannelBeta: + return true + case ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannelGa: + return true + case ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannelInternal: + return true + case ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannelPreview: + return true + case ProjectUpgradeEligibilityResponseCurrentAppVersionReleaseChannelWithdrawn: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseTargetUpgradeVersionsPostgresVersion. const ( N13 ProjectUpgradeEligibilityResponseTargetUpgradeVersionsPostgresVersion = "13" @@ -865,6 +2555,24 @@ const ( N17Oriole ProjectUpgradeEligibilityResponseTargetUpgradeVersionsPostgresVersion = "17-oriole" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseTargetUpgradeVersionsPostgresVersion enum. +func (e ProjectUpgradeEligibilityResponseTargetUpgradeVersionsPostgresVersion) 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 ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannel. const ( ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannelAlpha ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannel = "alpha" @@ -875,77 +2583,239 @@ const ( ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannelWithdrawn ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannel = "withdrawn" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannel enum. +func (e ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannel) Valid() bool { + switch e { + case ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannelAlpha: + return true + case ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannelBeta: + return true + case ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannelGa: + return true + case ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannelInternal: + return true + case ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannelPreview: + return true + case ProjectUpgradeEligibilityResponseTargetUpgradeVersionsReleaseChannelWithdrawn: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors0Type. const ( ObjectsDependingOnPgCron ProjectUpgradeEligibilityResponseValidationErrors0Type = "objects_depending_on_pg_cron" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors0Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors0Type) Valid() bool { + switch e { + case ObjectsDependingOnPgCron: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors1Type. const ( IndexesReferencingLlToEarth ProjectUpgradeEligibilityResponseValidationErrors1Type = "indexes_referencing_ll_to_earth" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors1Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors1Type) Valid() bool { + switch e { + case IndexesReferencingLlToEarth: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors2Type. const ( FunctionUsingObsoleteLang ProjectUpgradeEligibilityResponseValidationErrors2Type = "function_using_obsolete_lang" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors2Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors2Type) Valid() bool { + switch e { + case FunctionUsingObsoleteLang: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors3Type. const ( UnsupportedExtension ProjectUpgradeEligibilityResponseValidationErrors3Type = "unsupported_extension" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors3Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors3Type) Valid() bool { + switch e { + case UnsupportedExtension: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors4Type. const ( UnsupportedFdwHandler ProjectUpgradeEligibilityResponseValidationErrors4Type = "unsupported_fdw_handler" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors4Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors4Type) Valid() bool { + switch e { + case UnsupportedFdwHandler: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors5Type. const ( UnloggedTableWithPersistentSequence ProjectUpgradeEligibilityResponseValidationErrors5Type = "unlogged_table_with_persistent_sequence" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors5Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors5Type) Valid() bool { + switch e { + case UnloggedTableWithPersistentSequence: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors6ObjType. const ( ProjectUpgradeEligibilityResponseValidationErrors6ObjTypeFunction ProjectUpgradeEligibilityResponseValidationErrors6ObjType = "function" ProjectUpgradeEligibilityResponseValidationErrors6ObjTypeTable ProjectUpgradeEligibilityResponseValidationErrors6ObjType = "table" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors6ObjType enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors6ObjType) Valid() bool { + switch e { + case ProjectUpgradeEligibilityResponseValidationErrors6ObjTypeFunction: + return true + case ProjectUpgradeEligibilityResponseValidationErrors6ObjTypeTable: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors6Type. const ( UserDefinedObjectsInInternalSchemas ProjectUpgradeEligibilityResponseValidationErrors6Type = "user_defined_objects_in_internal_schemas" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors6Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors6Type) Valid() bool { + switch e { + case UserDefinedObjectsInInternalSchemas: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors7Type. const ( ActiveReplicationSlot ProjectUpgradeEligibilityResponseValidationErrors7Type = "active_replication_slot" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors7Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors7Type) Valid() bool { + switch e { + case ActiveReplicationSlot: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors8Type. const ( X86Architecture ProjectUpgradeEligibilityResponseValidationErrors8Type = "x86_architecture" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors8Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors8Type) Valid() bool { + switch e { + case X86Architecture: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseValidationErrors9Type. const ( ProjectHibernating ProjectUpgradeEligibilityResponseValidationErrors9Type = "project_hibernating" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseValidationErrors9Type enum. +func (e ProjectUpgradeEligibilityResponseValidationErrors9Type) Valid() bool { + switch e { + case ProjectHibernating: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseWarnings0Type. const ( PgGraphqlIntrospectionChange ProjectUpgradeEligibilityResponseWarnings0Type = "pg_graphql_introspection_change" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseWarnings0Type enum. +func (e ProjectUpgradeEligibilityResponseWarnings0Type) Valid() bool { + switch e { + case PgGraphqlIntrospectionChange: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseWarnings1Type. const ( LtreeReindexRequired ProjectUpgradeEligibilityResponseWarnings1Type = "ltree_reindex_required" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseWarnings1Type enum. +func (e ProjectUpgradeEligibilityResponseWarnings1Type) Valid() bool { + switch e { + case LtreeReindexRequired: + return true + default: + return false + } +} + // Defines values for ProjectUpgradeEligibilityResponseWarnings2Type. const ( OperatorEstimatorGate ProjectUpgradeEligibilityResponseWarnings2Type = "operator_estimator_gate" ) +// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseWarnings2Type enum. +func (e ProjectUpgradeEligibilityResponseWarnings2Type) Valid() bool { + switch e { + case OperatorEstimatorGate: + return true + default: + return false + } +} + // Defines values for RegionsInfoAllSmartGroupCode. const ( RegionsInfoAllSmartGroupCodeAmericas RegionsInfoAllSmartGroupCode = "americas" @@ -953,11 +2823,35 @@ const ( RegionsInfoAllSmartGroupCodeEmea RegionsInfoAllSmartGroupCode = "emea" ) +// Valid indicates whether the value is a known member of the RegionsInfoAllSmartGroupCode enum. +func (e RegionsInfoAllSmartGroupCode) Valid() bool { + switch e { + case RegionsInfoAllSmartGroupCodeAmericas: + return true + case RegionsInfoAllSmartGroupCodeApac: + return true + case RegionsInfoAllSmartGroupCodeEmea: + return true + default: + return false + } +} + // Defines values for RegionsInfoAllSmartGroupType. const ( RegionsInfoAllSmartGroupTypeSmartGroup RegionsInfoAllSmartGroupType = "smartGroup" ) +// Valid indicates whether the value is a known member of the RegionsInfoAllSmartGroupType enum. +func (e RegionsInfoAllSmartGroupType) Valid() bool { + switch e { + case RegionsInfoAllSmartGroupTypeSmartGroup: + return true + default: + return false + } +} + // Defines values for RegionsInfoAllSpecificCode. const ( RegionsInfoAllSpecificCodeApEast1 RegionsInfoAllSpecificCode = "ap-east-1" @@ -980,6 +2874,50 @@ const ( RegionsInfoAllSpecificCodeUsWest2 RegionsInfoAllSpecificCode = "us-west-2" ) +// Valid indicates whether the value is a known member of the RegionsInfoAllSpecificCode enum. +func (e RegionsInfoAllSpecificCode) Valid() bool { + switch e { + case RegionsInfoAllSpecificCodeApEast1: + return true + case RegionsInfoAllSpecificCodeApNortheast1: + return true + case RegionsInfoAllSpecificCodeApNortheast2: + return true + case RegionsInfoAllSpecificCodeApSouth1: + return true + case RegionsInfoAllSpecificCodeApSoutheast1: + return true + case RegionsInfoAllSpecificCodeApSoutheast2: + return true + case RegionsInfoAllSpecificCodeCaCentral1: + return true + case RegionsInfoAllSpecificCodeEuCentral1: + return true + case RegionsInfoAllSpecificCodeEuCentral2: + return true + case RegionsInfoAllSpecificCodeEuNorth1: + return true + case RegionsInfoAllSpecificCodeEuWest1: + return true + case RegionsInfoAllSpecificCodeEuWest2: + return true + case RegionsInfoAllSpecificCodeEuWest3: + return true + case RegionsInfoAllSpecificCodeSaEast1: + return true + case RegionsInfoAllSpecificCodeUsEast1: + return true + case RegionsInfoAllSpecificCodeUsEast2: + return true + case RegionsInfoAllSpecificCodeUsWest1: + return true + case RegionsInfoAllSpecificCodeUsWest2: + return true + default: + return false + } +} + // Defines values for RegionsInfoAllSpecificProvider. const ( RegionsInfoAllSpecificProviderAWS RegionsInfoAllSpecificProvider = "AWS" @@ -988,17 +2926,55 @@ const ( RegionsInfoAllSpecificProviderFLY RegionsInfoAllSpecificProvider = "FLY" ) +// Valid indicates whether the value is a known member of the RegionsInfoAllSpecificProvider enum. +func (e RegionsInfoAllSpecificProvider) Valid() bool { + switch e { + case RegionsInfoAllSpecificProviderAWS: + return true + case RegionsInfoAllSpecificProviderAWSK8S: + return true + case RegionsInfoAllSpecificProviderAWSNIMBUS: + return true + case RegionsInfoAllSpecificProviderFLY: + return true + default: + return false + } +} + // Defines values for RegionsInfoAllSpecificStatus. const ( RegionsInfoAllSpecificStatusCapacity RegionsInfoAllSpecificStatus = "capacity" RegionsInfoAllSpecificStatusOther RegionsInfoAllSpecificStatus = "other" ) +// Valid indicates whether the value is a known member of the RegionsInfoAllSpecificStatus enum. +func (e RegionsInfoAllSpecificStatus) Valid() bool { + switch e { + case RegionsInfoAllSpecificStatusCapacity: + return true + case RegionsInfoAllSpecificStatusOther: + return true + default: + return false + } +} + // Defines values for RegionsInfoAllSpecificType. const ( RegionsInfoAllSpecificTypeSpecific RegionsInfoAllSpecificType = "specific" ) +// Valid indicates whether the value is a known member of the RegionsInfoAllSpecificType enum. +func (e RegionsInfoAllSpecificType) Valid() bool { + switch e { + case RegionsInfoAllSpecificTypeSpecific: + return true + default: + return false + } +} + // Defines values for RegionsInfoRecommendationsSmartGroupCode. const ( RegionsInfoRecommendationsSmartGroupCodeAmericas RegionsInfoRecommendationsSmartGroupCode = "americas" @@ -1006,11 +2982,35 @@ const ( RegionsInfoRecommendationsSmartGroupCodeEmea RegionsInfoRecommendationsSmartGroupCode = "emea" ) +// Valid indicates whether the value is a known member of the RegionsInfoRecommendationsSmartGroupCode enum. +func (e RegionsInfoRecommendationsSmartGroupCode) Valid() bool { + switch e { + case RegionsInfoRecommendationsSmartGroupCodeAmericas: + return true + case RegionsInfoRecommendationsSmartGroupCodeApac: + return true + case RegionsInfoRecommendationsSmartGroupCodeEmea: + return true + default: + return false + } +} + // Defines values for RegionsInfoRecommendationsSmartGroupType. const ( RegionsInfoRecommendationsSmartGroupTypeSmartGroup RegionsInfoRecommendationsSmartGroupType = "smartGroup" ) +// Valid indicates whether the value is a known member of the RegionsInfoRecommendationsSmartGroupType enum. +func (e RegionsInfoRecommendationsSmartGroupType) Valid() bool { + switch e { + case RegionsInfoRecommendationsSmartGroupTypeSmartGroup: + return true + default: + return false + } +} + // Defines values for RegionsInfoRecommendationsSpecificCode. const ( RegionsInfoRecommendationsSpecificCodeApEast1 RegionsInfoRecommendationsSpecificCode = "ap-east-1" @@ -1033,6 +3033,50 @@ const ( RegionsInfoRecommendationsSpecificCodeUsWest2 RegionsInfoRecommendationsSpecificCode = "us-west-2" ) +// Valid indicates whether the value is a known member of the RegionsInfoRecommendationsSpecificCode enum. +func (e RegionsInfoRecommendationsSpecificCode) Valid() bool { + switch e { + case RegionsInfoRecommendationsSpecificCodeApEast1: + return true + case RegionsInfoRecommendationsSpecificCodeApNortheast1: + return true + case RegionsInfoRecommendationsSpecificCodeApNortheast2: + return true + case RegionsInfoRecommendationsSpecificCodeApSouth1: + return true + case RegionsInfoRecommendationsSpecificCodeApSoutheast1: + return true + case RegionsInfoRecommendationsSpecificCodeApSoutheast2: + return true + case RegionsInfoRecommendationsSpecificCodeCaCentral1: + return true + case RegionsInfoRecommendationsSpecificCodeEuCentral1: + return true + case RegionsInfoRecommendationsSpecificCodeEuCentral2: + return true + case RegionsInfoRecommendationsSpecificCodeEuNorth1: + return true + case RegionsInfoRecommendationsSpecificCodeEuWest1: + return true + case RegionsInfoRecommendationsSpecificCodeEuWest2: + return true + case RegionsInfoRecommendationsSpecificCodeEuWest3: + return true + case RegionsInfoRecommendationsSpecificCodeSaEast1: + return true + case RegionsInfoRecommendationsSpecificCodeUsEast1: + return true + case RegionsInfoRecommendationsSpecificCodeUsEast2: + return true + case RegionsInfoRecommendationsSpecificCodeUsWest1: + return true + case RegionsInfoRecommendationsSpecificCodeUsWest2: + return true + default: + return false + } +} + // Defines values for RegionsInfoRecommendationsSpecificProvider. const ( RegionsInfoRecommendationsSpecificProviderAWS RegionsInfoRecommendationsSpecificProvider = "AWS" @@ -1041,17 +3085,55 @@ const ( RegionsInfoRecommendationsSpecificProviderFLY RegionsInfoRecommendationsSpecificProvider = "FLY" ) +// Valid indicates whether the value is a known member of the RegionsInfoRecommendationsSpecificProvider enum. +func (e RegionsInfoRecommendationsSpecificProvider) Valid() bool { + switch e { + case RegionsInfoRecommendationsSpecificProviderAWS: + return true + case RegionsInfoRecommendationsSpecificProviderAWSK8S: + return true + case RegionsInfoRecommendationsSpecificProviderAWSNIMBUS: + return true + case RegionsInfoRecommendationsSpecificProviderFLY: + return true + default: + return false + } +} + // Defines values for RegionsInfoRecommendationsSpecificStatus. const ( RegionsInfoRecommendationsSpecificStatusCapacity RegionsInfoRecommendationsSpecificStatus = "capacity" RegionsInfoRecommendationsSpecificStatusOther RegionsInfoRecommendationsSpecificStatus = "other" ) +// Valid indicates whether the value is a known member of the RegionsInfoRecommendationsSpecificStatus enum. +func (e RegionsInfoRecommendationsSpecificStatus) Valid() bool { + switch e { + case RegionsInfoRecommendationsSpecificStatusCapacity: + return true + case RegionsInfoRecommendationsSpecificStatusOther: + return true + default: + return false + } +} + // Defines values for RegionsInfoRecommendationsSpecificType. const ( RegionsInfoRecommendationsSpecificTypeSpecific RegionsInfoRecommendationsSpecificType = "specific" ) +// Valid indicates whether the value is a known member of the RegionsInfoRecommendationsSpecificType enum. +func (e RegionsInfoRecommendationsSpecificType) Valid() bool { + switch e { + case RegionsInfoRecommendationsSpecificTypeSpecific: + return true + default: + return false + } +} + // Defines values for SetUpReadReplicaBodyReadReplicaRegion. const ( SetUpReadReplicaBodyReadReplicaRegionApEast1 SetUpReadReplicaBodyReadReplicaRegion = "ap-east-1" @@ -1074,6 +3156,50 @@ const ( 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 SigningKeyResponseAlgorithm. const ( SigningKeyResponseAlgorithmES256 SigningKeyResponseAlgorithm = "ES256" @@ -1082,6 +3208,22 @@ const ( SigningKeyResponseAlgorithmRS256 SigningKeyResponseAlgorithm = "RS256" ) +// Valid indicates whether the value is a known member of the SigningKeyResponseAlgorithm enum. +func (e SigningKeyResponseAlgorithm) Valid() bool { + switch e { + case SigningKeyResponseAlgorithmES256: + return true + case SigningKeyResponseAlgorithmEdDSA: + return true + case SigningKeyResponseAlgorithmHS256: + return true + case SigningKeyResponseAlgorithmRS256: + return true + default: + return false + } +} + // Defines values for SigningKeyResponseStatus. const ( SigningKeyResponseStatusInUse SigningKeyResponseStatus = "in_use" @@ -1090,6 +3232,22 @@ const ( SigningKeyResponseStatusStandby SigningKeyResponseStatus = "standby" ) +// Valid indicates whether the value is a known member of the SigningKeyResponseStatus enum. +func (e SigningKeyResponseStatus) Valid() bool { + switch e { + case SigningKeyResponseStatusInUse: + return true + case SigningKeyResponseStatusPreviouslyUsed: + return true + case SigningKeyResponseStatusRevoked: + return true + case SigningKeyResponseStatusStandby: + return true + default: + return false + } +} + // Defines values for SigningKeysResponseKeysAlgorithm. const ( SigningKeysResponseKeysAlgorithmES256 SigningKeysResponseKeysAlgorithm = "ES256" @@ -1098,6 +3256,22 @@ const ( SigningKeysResponseKeysAlgorithmRS256 SigningKeysResponseKeysAlgorithm = "RS256" ) +// Valid indicates whether the value is a known member of the SigningKeysResponseKeysAlgorithm enum. +func (e SigningKeysResponseKeysAlgorithm) Valid() bool { + switch e { + case SigningKeysResponseKeysAlgorithmES256: + return true + case SigningKeysResponseKeysAlgorithmEdDSA: + return true + case SigningKeysResponseKeysAlgorithmHS256: + return true + case SigningKeysResponseKeysAlgorithmRS256: + return true + default: + return false + } +} + // Defines values for SigningKeysResponseKeysStatus. const ( SigningKeysResponseKeysStatusInUse SigningKeysResponseKeysStatus = "in_use" @@ -1106,11 +3280,37 @@ const ( SigningKeysResponseKeysStatusStandby SigningKeysResponseKeysStatus = "standby" ) +// Valid indicates whether the value is a known member of the SigningKeysResponseKeysStatus enum. +func (e SigningKeysResponseKeysStatus) Valid() bool { + switch e { + case SigningKeysResponseKeysStatusInUse: + return true + case SigningKeysResponseKeysStatusPreviouslyUsed: + return true + case SigningKeysResponseKeysStatusRevoked: + return true + case SigningKeysResponseKeysStatusStandby: + return true + default: + return false + } +} + // Defines values for SnippetListDataType. const ( SnippetListDataTypeSql SnippetListDataType = "sql" ) +// Valid indicates whether the value is a known member of the SnippetListDataType enum. +func (e SnippetListDataType) Valid() bool { + switch e { + case SnippetListDataTypeSql: + return true + default: + return false + } +} + // Defines values for SnippetListDataVisibility. const ( SnippetListDataVisibilityOrg SnippetListDataVisibility = "org" @@ -1119,11 +3319,37 @@ const ( SnippetListDataVisibilityUser SnippetListDataVisibility = "user" ) +// Valid indicates whether the value is a known member of the SnippetListDataVisibility enum. +func (e SnippetListDataVisibility) Valid() bool { + switch e { + case SnippetListDataVisibilityOrg: + return true + case SnippetListDataVisibilityProject: + return true + case SnippetListDataVisibilityPublic: + return true + case SnippetListDataVisibilityUser: + return true + default: + return false + } +} + // Defines values for SnippetResponseType. const ( SnippetResponseTypeSql SnippetResponseType = "sql" ) +// Valid indicates whether the value is a known member of the SnippetResponseType enum. +func (e SnippetResponseType) Valid() bool { + switch e { + case SnippetResponseTypeSql: + return true + default: + return false + } +} + // Defines values for SnippetResponseVisibility. const ( SnippetResponseVisibilityOrg SnippetResponseVisibility = "org" @@ -1132,30 +3358,94 @@ const ( SnippetResponseVisibilityUser SnippetResponseVisibility = "user" ) +// Valid indicates whether the value is a known member of the SnippetResponseVisibility enum. +func (e SnippetResponseVisibility) Valid() bool { + switch e { + case SnippetResponseVisibilityOrg: + return true + case SnippetResponseVisibilityProject: + return true + case SnippetResponseVisibilityPublic: + return true + case SnippetResponseVisibilityUser: + return true + default: + return false + } +} + // Defines values for StorageConfigResponseExternalUpstreamTarget. const ( StorageConfigResponseExternalUpstreamTargetCanary StorageConfigResponseExternalUpstreamTarget = "canary" StorageConfigResponseExternalUpstreamTargetMain StorageConfigResponseExternalUpstreamTarget = "main" ) +// Valid indicates whether the value is a known member of the StorageConfigResponseExternalUpstreamTarget enum. +func (e StorageConfigResponseExternalUpstreamTarget) Valid() bool { + switch e { + case StorageConfigResponseExternalUpstreamTargetCanary: + return true + case StorageConfigResponseExternalUpstreamTargetMain: + return true + default: + return false + } +} + // Defines values for SupavisorConfigResponseDatabaseType. const ( SupavisorConfigResponseDatabaseTypePRIMARY SupavisorConfigResponseDatabaseType = "PRIMARY" SupavisorConfigResponseDatabaseTypeREADREPLICA SupavisorConfigResponseDatabaseType = "READ_REPLICA" ) +// Valid indicates whether the value is a known member of the SupavisorConfigResponseDatabaseType enum. +func (e SupavisorConfigResponseDatabaseType) Valid() bool { + switch e { + case SupavisorConfigResponseDatabaseTypePRIMARY: + return true + case SupavisorConfigResponseDatabaseTypeREADREPLICA: + return true + default: + return false + } +} + // Defines values for SupavisorConfigResponsePoolMode. const ( SupavisorConfigResponsePoolModeSession SupavisorConfigResponsePoolMode = "session" SupavisorConfigResponsePoolModeTransaction SupavisorConfigResponsePoolMode = "transaction" ) +// Valid indicates whether the value is a known member of the SupavisorConfigResponsePoolMode enum. +func (e SupavisorConfigResponsePoolMode) Valid() bool { + switch e { + case SupavisorConfigResponsePoolModeSession: + return true + case SupavisorConfigResponsePoolModeTransaction: + return true + default: + return false + } +} + // Defines values for UpdateAuthConfigBodyDbMaxPoolSizeUnit. const ( UpdateAuthConfigBodyDbMaxPoolSizeUnitConnections UpdateAuthConfigBodyDbMaxPoolSizeUnit = "connections" 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 UpdateAuthConfigBodyDbMaxPoolSizeUnitPercent: + return true + default: + return false + } +} + // Defines values for UpdateAuthConfigBodyPasswordRequiredCharacters. const ( UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 UpdateAuthConfigBodyPasswordRequiredCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789" @@ -1164,12 +3454,40 @@ const ( 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" 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 UpdateAuthConfigBodySecurityCaptchaProviderTurnstile: + return true + default: + return false + } +} + // Defines values for UpdateAuthConfigBodySmsProvider. const ( UpdateAuthConfigBodySmsProviderMessagebird UpdateAuthConfigBodySmsProvider = "messagebird" @@ -1179,6 +3497,24 @@ const ( UpdateAuthConfigBodySmsProviderVonage UpdateAuthConfigBodySmsProvider = "vonage" ) +// Valid indicates whether the value is a known member of the UpdateAuthConfigBodySmsProvider enum. +func (e UpdateAuthConfigBodySmsProvider) Valid() bool { + switch e { + case UpdateAuthConfigBodySmsProviderMessagebird: + return true + case UpdateAuthConfigBodySmsProviderTextlocal: + return true + case UpdateAuthConfigBodySmsProviderTwilio: + return true + case UpdateAuthConfigBodySmsProviderTwilioVerify: + return true + case UpdateAuthConfigBodySmsProviderVonage: + return true + default: + return false + } +} + // Defines values for UpdateBranchBodyStatus. const ( UpdateBranchBodyStatusCREATINGPROJECT UpdateBranchBodyStatus = "CREATING_PROJECT" @@ -1189,6 +3525,26 @@ const ( 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 UpdateCustomHostnameResponseStatus. const ( N1NotStarted UpdateCustomHostnameResponseStatus = "1_not_started" @@ -1198,6 +3554,24 @@ const ( N5ServicesReconfigured UpdateCustomHostnameResponseStatus = "5_services_reconfigured" ) +// Valid indicates whether the value is a known member of the UpdateCustomHostnameResponseStatus enum. +func (e UpdateCustomHostnameResponseStatus) 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" @@ -1205,6 +3579,20 @@ const ( 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" @@ -1213,6 +3601,22 @@ const ( 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" @@ -1224,6 +3628,28 @@ const ( 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" @@ -1235,6 +3661,28 @@ const ( 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" @@ -1246,6 +3694,28 @@ const ( 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" @@ -1257,6 +3727,28 @@ const ( 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" @@ -1268,6 +3760,28 @@ const ( 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" @@ -1279,6 +3793,28 @@ const ( 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" @@ -1290,11 +3826,43 @@ const ( 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 UpdateRunStatusResponseMessage. const ( UpdateRunStatusResponseMessageOk UpdateRunStatusResponseMessage = "ok" ) +// Valid indicates whether the value is a known member of the UpdateRunStatusResponseMessage enum. +func (e UpdateRunStatusResponseMessage) Valid() bool { + switch e { + case UpdateRunStatusResponseMessageOk: + return true + default: + return false + } +} + // Defines values for UpdateSigningKeyBodyStatus. const ( UpdateSigningKeyBodyStatusInUse UpdateSigningKeyBodyStatus = "in_use" @@ -1303,18 +3871,58 @@ const ( 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" @@ -1325,6 +3933,26 @@ const ( 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 V1BackupsResponseBackupsStatus. const ( V1BackupsResponseBackupsStatusARCHIVED V1BackupsResponseBackupsStatus = "ARCHIVED" @@ -1335,6 +3963,26 @@ const ( V1BackupsResponseBackupsStatusREMOVED V1BackupsResponseBackupsStatus = "REMOVED" ) +// Valid indicates whether the value is a known member of the V1BackupsResponseBackupsStatus enum. +func (e V1BackupsResponseBackupsStatus) Valid() bool { + switch e { + case V1BackupsResponseBackupsStatusARCHIVED: + return true + case V1BackupsResponseBackupsStatusCANCELLED: + return true + case V1BackupsResponseBackupsStatusCOMPLETED: + return true + case V1BackupsResponseBackupsStatusFAILED: + return true + case V1BackupsResponseBackupsStatusPENDING: + return true + case V1BackupsResponseBackupsStatusREMOVED: + return true + default: + return false + } +} + // Defines values for V1CreateProjectBodyDesiredInstanceSize. const ( V1CreateProjectBodyDesiredInstanceSizeLarge V1CreateProjectBodyDesiredInstanceSize = "large" @@ -1358,12 +4006,70 @@ const ( 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" @@ -1386,6 +4092,50 @@ const ( 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" @@ -1408,11 +4158,65 @@ const ( 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" @@ -1420,11 +4224,35 @@ const ( 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 V1ListEntitlementsResponseEntitlementsFeatureKey. const ( V1ListEntitlementsResponseEntitlementsFeatureKeyApiMembersInvitations V1ListEntitlementsResponseEntitlementsFeatureKey = "api.members.invitations" @@ -1492,6 +4320,140 @@ const ( V1ListEntitlementsResponseEntitlementsFeatureKeyVanitySubdomain V1ListEntitlementsResponseEntitlementsFeatureKey = "vanity_subdomain" ) +// Valid indicates whether the value is a known member of the V1ListEntitlementsResponseEntitlementsFeatureKey enum. +func (e V1ListEntitlementsResponseEntitlementsFeatureKey) Valid() bool { + switch e { + case V1ListEntitlementsResponseEntitlementsFeatureKeyApiMembersInvitations: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyApiMembersRoles: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAssistantAdvanceModel: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuditLogDrains: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthAdvancedAuthSettings: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthCustomJwtTemplate: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthCustomOauthMaxProviders: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthHooks: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthLeakedPasswordProtection: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthMfaEnhancedSecurity: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthMfaPhone: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthMfaWebAuthn: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthPasswordHibp: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthPerformanceSettings: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthPlatformSso: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthSaml2: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyAuthUserSessions: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyBackupRestoreToNewProject: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyBackupRetentionDays: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyBackupSchedule: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyBranchingLimit: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyBranchingPersistent: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyCustomDomain: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyDedicatedPooler: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyFunctionMaxCount: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyFunctionSizeLimitMb: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesComputeUpdateAvailableSizes: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesDiskModifications: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesHighAvailability: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesOrioledb: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesReadReplicas: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubConnections: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyIpv4: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyLogDrains: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyLogRetentionDays: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyObservabilityDashboardAdvancedMetrics: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyPitrAvailableVariants: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyProjectCloning: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyProjectPausing: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyProjectRestoreAfterExpiry: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyProjectScopedRoles: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxBytesPerSecond: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxChannelsPerClient: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxConcurrentUsers: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxEventsPerSecond: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxJoinsPerSecond: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxPayloadSizeInKb: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxPresenceEventsPerSecond: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyReplicationEtl: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeySecurityAuditLogsDays: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeySecurityEnforceMfa: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeySecurityIso27001Certificate: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeySecurityMemberRoles: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeySecurityPrivateLink: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeySecurityQuestionnaire: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeySecuritySoc2Report: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyStorageIcebergCatalog: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyStorageImageTransformations: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyStorageMaxFileSize: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyStorageMaxFileSizeConfigurable: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyStoragePurgeCache: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyStorageVectorBuckets: + return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyVanitySubdomain: + return true + default: + return false + } +} + // Defines values for V1ListEntitlementsResponseEntitlementsFeatureType. const ( V1ListEntitlementsResponseEntitlementsFeatureTypeBoolean V1ListEntitlementsResponseEntitlementsFeatureType = "boolean" @@ -1499,6 +4461,20 @@ const ( V1ListEntitlementsResponseEntitlementsFeatureTypeSet V1ListEntitlementsResponseEntitlementsFeatureType = "set" ) +// Valid indicates whether the value is a known member of the V1ListEntitlementsResponseEntitlementsFeatureType enum. +func (e V1ListEntitlementsResponseEntitlementsFeatureType) Valid() bool { + switch e { + case V1ListEntitlementsResponseEntitlementsFeatureTypeBoolean: + return true + case V1ListEntitlementsResponseEntitlementsFeatureTypeNumeric: + return true + case V1ListEntitlementsResponseEntitlementsFeatureTypeSet: + return true + default: + return false + } +} + // Defines values for V1ListEntitlementsResponseEntitlementsType. const ( V1ListEntitlementsResponseEntitlementsTypeBoolean V1ListEntitlementsResponseEntitlementsType = "boolean" @@ -1506,6 +4482,20 @@ const ( V1ListEntitlementsResponseEntitlementsTypeSet V1ListEntitlementsResponseEntitlementsType = "set" ) +// Valid indicates whether the value is a known member of the V1ListEntitlementsResponseEntitlementsType enum. +func (e V1ListEntitlementsResponseEntitlementsType) Valid() bool { + switch e { + case V1ListEntitlementsResponseEntitlementsTypeBoolean: + return true + case V1ListEntitlementsResponseEntitlementsTypeNumeric: + return true + case V1ListEntitlementsResponseEntitlementsTypeSet: + return true + default: + return false + } +} + // Defines values for V1OrganizationSlugResponseAllowedReleaseChannels. const ( V1OrganizationSlugResponseAllowedReleaseChannelsAlpha V1OrganizationSlugResponseAllowedReleaseChannels = "alpha" @@ -1516,6 +4506,26 @@ const ( V1OrganizationSlugResponseAllowedReleaseChannelsWithdrawn V1OrganizationSlugResponseAllowedReleaseChannels = "withdrawn" ) +// Valid indicates whether the value is a known member of the V1OrganizationSlugResponseAllowedReleaseChannels enum. +func (e V1OrganizationSlugResponseAllowedReleaseChannels) Valid() bool { + switch e { + case V1OrganizationSlugResponseAllowedReleaseChannelsAlpha: + return true + case V1OrganizationSlugResponseAllowedReleaseChannelsBeta: + return true + case V1OrganizationSlugResponseAllowedReleaseChannelsGa: + return true + case V1OrganizationSlugResponseAllowedReleaseChannelsInternal: + return true + case V1OrganizationSlugResponseAllowedReleaseChannelsPreview: + return true + case V1OrganizationSlugResponseAllowedReleaseChannelsWithdrawn: + return true + default: + return false + } +} + // Defines values for V1OrganizationSlugResponseOptInTags. const ( AIDATAGENERATOROPTIN V1OrganizationSlugResponseOptInTags = "AI_DATA_GENERATOR_OPT_IN" @@ -1523,6 +4533,20 @@ const ( AISQLGENERATOROPTIN V1OrganizationSlugResponseOptInTags = "AI_SQL_GENERATOR_OPT_IN" ) +// Valid indicates whether the value is a known member of the V1OrganizationSlugResponseOptInTags enum. +func (e V1OrganizationSlugResponseOptInTags) Valid() bool { + switch e { + case AIDATAGENERATOROPTIN: + return true + case AILOGGENERATOROPTIN: + return true + case AISQLGENERATOROPTIN: + return true + default: + return false + } +} + // Defines values for V1OrganizationSlugResponsePlan. const ( V1OrganizationSlugResponsePlanEnterprise V1OrganizationSlugResponsePlan = "enterprise" @@ -1532,6 +4556,24 @@ const ( V1OrganizationSlugResponsePlanTeam V1OrganizationSlugResponsePlan = "team" ) +// Valid indicates whether the value is a known member of the V1OrganizationSlugResponsePlan enum. +func (e V1OrganizationSlugResponsePlan) Valid() bool { + switch e { + case V1OrganizationSlugResponsePlanEnterprise: + return true + case V1OrganizationSlugResponsePlanFree: + return true + case V1OrganizationSlugResponsePlanPlatform: + return true + case V1OrganizationSlugResponsePlanPro: + return true + case V1OrganizationSlugResponsePlanTeam: + return true + default: + return false + } +} + // Defines values for V1PgbouncerConfigResponsePoolMode. const ( Session V1PgbouncerConfigResponsePoolMode = "session" @@ -1539,17 +4581,53 @@ const ( Transaction V1PgbouncerConfigResponsePoolMode = "transaction" ) +// Valid indicates whether the value is a known member of the V1PgbouncerConfigResponsePoolMode enum. +func (e V1PgbouncerConfigResponsePoolMode) Valid() bool { + switch e { + case Session: + return true + case Statement: + return true + case Transaction: + return true + default: + return false + } +} + // Defines values for V1ProjectAdvisorsResponseLintsCategories. const ( PERFORMANCE V1ProjectAdvisorsResponseLintsCategories = "PERFORMANCE" SECURITY V1ProjectAdvisorsResponseLintsCategories = "SECURITY" ) +// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseLintsCategories enum. +func (e V1ProjectAdvisorsResponseLintsCategories) Valid() bool { + switch e { + case PERFORMANCE: + return true + case SECURITY: + return true + default: + return false + } +} + // Defines values for V1ProjectAdvisorsResponseLintsFacing. const ( EXTERNAL V1ProjectAdvisorsResponseLintsFacing = "EXTERNAL" ) +// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseLintsFacing enum. +func (e V1ProjectAdvisorsResponseLintsFacing) Valid() bool { + switch e { + case EXTERNAL: + return true + default: + return false + } +} + // Defines values for V1ProjectAdvisorsResponseLintsLevel. const ( ERROR V1ProjectAdvisorsResponseLintsLevel = "ERROR" @@ -1557,6 +4635,20 @@ const ( WARN V1ProjectAdvisorsResponseLintsLevel = "WARN" ) +// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseLintsLevel enum. +func (e V1ProjectAdvisorsResponseLintsLevel) Valid() bool { + switch e { + case ERROR: + return true + case INFO: + return true + case WARN: + return true + default: + return false + } +} + // Defines values for V1ProjectAdvisorsResponseLintsMetadataType. const ( V1ProjectAdvisorsResponseLintsMetadataTypeAuth V1ProjectAdvisorsResponseLintsMetadataType = "auth" @@ -1567,6 +4659,26 @@ const ( V1ProjectAdvisorsResponseLintsMetadataTypeView V1ProjectAdvisorsResponseLintsMetadataType = "view" ) +// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseLintsMetadataType enum. +func (e V1ProjectAdvisorsResponseLintsMetadataType) Valid() bool { + switch e { + case V1ProjectAdvisorsResponseLintsMetadataTypeAuth: + return true + case V1ProjectAdvisorsResponseLintsMetadataTypeCompliance: + return true + case V1ProjectAdvisorsResponseLintsMetadataTypeExtension: + return true + case V1ProjectAdvisorsResponseLintsMetadataTypeFunction: + return true + case V1ProjectAdvisorsResponseLintsMetadataTypeTable: + return true + case V1ProjectAdvisorsResponseLintsMetadataTypeView: + return true + default: + return false + } +} + // Defines values for V1ProjectAdvisorsResponseLintsName. const ( AuthInsufficientMfaOptions V1ProjectAdvisorsResponseLintsName = "auth_insufficient_mfa_options" @@ -1600,6 +4712,72 @@ const ( VulnerablePostgresVersion V1ProjectAdvisorsResponseLintsName = "vulnerable_postgres_version" ) +// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseLintsName enum. +func (e V1ProjectAdvisorsResponseLintsName) Valid() bool { + switch e { + 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 DuplicateIndex: + return true + case ExtensionInPublic: + return true + case ForeignTableInApi: + return true + case FunctionSearchPathMutable: + return true + case LeakedServiceKey: + 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 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 V1ProjectResponseStatus. const ( V1ProjectResponseStatusACTIVEHEALTHY V1ProjectResponseStatus = "ACTIVE_HEALTHY" @@ -1619,6 +4797,44 @@ const ( V1ProjectResponseStatusUPGRADING V1ProjectResponseStatus = "UPGRADING" ) +// Valid indicates whether the value is a known member of the V1ProjectResponseStatus enum. +func (e V1ProjectResponseStatus) Valid() bool { + switch e { + case V1ProjectResponseStatusACTIVEHEALTHY: + return true + case V1ProjectResponseStatusACTIVEUNHEALTHY: + return true + case V1ProjectResponseStatusCOMINGUP: + return true + case V1ProjectResponseStatusGOINGDOWN: + return true + case V1ProjectResponseStatusINACTIVE: + return true + case V1ProjectResponseStatusINITFAILED: + return true + case V1ProjectResponseStatusPAUSEFAILED: + return true + case V1ProjectResponseStatusPAUSING: + return true + case V1ProjectResponseStatusREMOVED: + return true + case V1ProjectResponseStatusRESIZING: + return true + case V1ProjectResponseStatusRESTARTING: + return true + case V1ProjectResponseStatusRESTOREFAILED: + return true + case V1ProjectResponseStatusRESTORING: + return true + case V1ProjectResponseStatusUNKNOWN: + return true + case V1ProjectResponseStatusUPGRADING: + return true + default: + return false + } +} + // Defines values for V1ProjectWithDatabaseResponseStatus. const ( V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY V1ProjectWithDatabaseResponseStatus = "ACTIVE_HEALTHY" @@ -1638,6 +4854,44 @@ const ( V1ProjectWithDatabaseResponseStatusUPGRADING V1ProjectWithDatabaseResponseStatus = "UPGRADING" ) +// Valid indicates whether the value is a known member of the V1ProjectWithDatabaseResponseStatus enum. +func (e V1ProjectWithDatabaseResponseStatus) Valid() bool { + switch e { + case V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY: + return true + case V1ProjectWithDatabaseResponseStatusACTIVEUNHEALTHY: + return true + case V1ProjectWithDatabaseResponseStatusCOMINGUP: + return true + case V1ProjectWithDatabaseResponseStatusGOINGDOWN: + return true + case V1ProjectWithDatabaseResponseStatusINACTIVE: + return true + case V1ProjectWithDatabaseResponseStatusINITFAILED: + return true + case V1ProjectWithDatabaseResponseStatusPAUSEFAILED: + return true + case V1ProjectWithDatabaseResponseStatusPAUSING: + return true + case V1ProjectWithDatabaseResponseStatusREMOVED: + return true + case V1ProjectWithDatabaseResponseStatusRESIZING: + return true + case V1ProjectWithDatabaseResponseStatusRESTARTING: + return true + case V1ProjectWithDatabaseResponseStatusRESTOREFAILED: + return true + case V1ProjectWithDatabaseResponseStatusRESTORING: + return true + case V1ProjectWithDatabaseResponseStatusUNKNOWN: + return true + case V1ProjectWithDatabaseResponseStatusUPGRADING: + return true + default: + return false + } +} + // Defines values for V1RestorePointResponseStatus. const ( V1RestorePointResponseStatusAVAILABLE V1RestorePointResponseStatus = "AVAILABLE" @@ -1646,11 +4900,37 @@ const ( 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 V1ServiceHealthResponseInfo0Name. const ( GoTrue V1ServiceHealthResponseInfo0Name = "GoTrue" ) +// Valid indicates whether the value is a known member of the V1ServiceHealthResponseInfo0Name enum. +func (e V1ServiceHealthResponseInfo0Name) Valid() bool { + switch e { + case GoTrue: + return true + default: + return false + } +} + // Defines values for V1ServiceHealthResponseName. const ( V1ServiceHealthResponseNameAuth V1ServiceHealthResponseName = "auth" @@ -1663,6 +4943,30 @@ const ( V1ServiceHealthResponseNameStorage V1ServiceHealthResponseName = "storage" ) +// Valid indicates whether the value is a known member of the V1ServiceHealthResponseName enum. +func (e V1ServiceHealthResponseName) Valid() bool { + switch e { + case V1ServiceHealthResponseNameAuth: + return true + case V1ServiceHealthResponseNameDb: + return true + case V1ServiceHealthResponseNameDbPostgresUser: + return true + case V1ServiceHealthResponseNamePgBouncer: + return true + case V1ServiceHealthResponseNamePooler: + return true + case V1ServiceHealthResponseNameRealtime: + return true + case V1ServiceHealthResponseNameRest: + return true + case V1ServiceHealthResponseNameStorage: + return true + default: + return false + } +} + // Defines values for V1ServiceHealthResponseStatus. const ( ACTIVEHEALTHY V1ServiceHealthResponseStatus = "ACTIVE_HEALTHY" @@ -1670,6 +4974,20 @@ const ( UNHEALTHY V1ServiceHealthResponseStatus = "UNHEALTHY" ) +// Valid indicates whether the value is a known member of the V1ServiceHealthResponseStatus enum. +func (e V1ServiceHealthResponseStatus) Valid() bool { + switch e { + case ACTIVEHEALTHY: + return true + case COMINGUP: + return true + case UNHEALTHY: + return true + default: + return false + } +} + // Defines values for VanitySubdomainConfigResponseStatus. const ( Active VanitySubdomainConfigResponseStatus = "active" @@ -1677,6 +4995,20 @@ const ( NotUsed VanitySubdomainConfigResponseStatus = "not-used" ) +// Valid indicates whether the value is a known member of the VanitySubdomainConfigResponseStatus enum. +func (e VanitySubdomainConfigResponseStatus) 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" @@ -1684,6 +5016,20 @@ const ( 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" @@ -1691,6 +5037,20 @@ const ( 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" @@ -1698,6 +5058,20 @@ const ( 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" @@ -1705,6 +5079,20 @@ const ( 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" @@ -1713,6 +5101,22 @@ const ( 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" @@ -1724,6 +5128,28 @@ const ( 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" @@ -1747,11 +5173,67 @@ const ( 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" @@ -1760,6 +5242,22 @@ const ( 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" @@ -1771,6 +5269,28 @@ const ( 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" @@ -1793,11 +5313,65 @@ const ( 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" @@ -1805,11 +5379,35 @@ const ( 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" @@ -1822,18 +5420,66 @@ const ( 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 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"` @@ -2237,7 +5883,7 @@ type BranchResponse struct { IsDefault bool `json:"is_default"` // LatestCheckRunId This field is deprecated and will not be populated. - // Deprecated: + // 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"` @@ -2249,7 +5895,7 @@ type BranchResponse struct { ReviewRequestedAt *time.Time `json:"review_requested_at,omitempty"` // Status This field is deprecated. List action runs to get branch status instead. - // Deprecated: + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set Status BranchResponseStatus `json:"status"` UpdatedAt time.Time `json:"updated_at"` WithData bool `json:"with_data"` @@ -2376,10 +6022,10 @@ type CreateProjectClaimTokenResponse struct { 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"` + 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"` @@ -2403,21 +6049,19 @@ type CreateProviderResponse struct { Domains *[]struct { CreatedAt *string `json:"created_at,omitempty"` Domain *string `json:"domain,omitempty"` - Id string `json:"id"` 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"` + 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"` - Id string `json:"id"` MetadataUrl *string `json:"metadata_url,omitempty"` MetadataXml *string `json:"metadata_xml,omitempty"` NameIdFormat *string `json:"name_id_format,omitempty"` @@ -2589,9 +6233,9 @@ 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"` + CustomJwks interface{} `json:"custom_jwks,omitempty"` + JwksUrl *string `json:"jwks_url,omitempty"` + OidcIssuerUrl *string `json:"oidc_issuer_url,omitempty"` } // DatabaseUpgradeStatusResponse defines model for DatabaseUpgradeStatusResponse. @@ -2618,21 +6262,19 @@ type DeleteProviderResponse struct { Domains *[]struct { CreatedAt *string `json:"created_at,omitempty"` Domain *string `json:"domain,omitempty"` - Id string `json:"id"` 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"` + 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"` - Id string `json:"id"` MetadataUrl *string `json:"metadata_url,omitempty"` MetadataXml *string `json:"metadata_xml,omitempty"` NameIdFormat *string `json:"name_id_format,omitempty"` @@ -2844,21 +6486,19 @@ type GetProviderResponse struct { Domains *[]struct { CreatedAt *string `json:"created_at,omitempty"` Domain *string `json:"domain,omitempty"` - Id string `json:"id"` 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"` + 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"` - Id string `json:"id"` MetadataUrl *string `json:"metadata_url,omitempty"` MetadataXml *string `json:"metadata_xml,omitempty"` NameIdFormat *string `json:"name_id_format,omitempty"` @@ -3062,8 +6702,8 @@ type ListProjectAddonsResponse struct { Id ListProjectAddonsResponse_AvailableAddons_Variants_Id `json:"id"` // Meta Any JSON-serializable value - Meta *interface{} `json:"meta,omitempty"` - Name string `json:"name"` + Meta interface{} `json:"meta,omitempty"` + Name string `json:"name"` Price struct { Amount float32 `json:"amount"` Description string `json:"description"` @@ -3078,8 +6718,8 @@ type ListProjectAddonsResponse struct { Id ListProjectAddonsResponse_SelectedAddons_Variant_Id `json:"id"` // Meta Any JSON-serializable value - Meta *interface{} `json:"meta,omitempty"` - Name string `json:"name"` + Meta interface{} `json:"meta,omitempty"` + Name string `json:"name"` Price struct { Amount float32 `json:"amount"` Description string `json:"description"` @@ -3173,21 +6813,19 @@ type ListProvidersResponse struct { Domains *[]struct { CreatedAt *string `json:"created_at,omitempty"` Domain *string `json:"domain,omitempty"` - Id string `json:"id"` 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"` + 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"` - Id string `json:"id"` MetadataUrl *string `json:"metadata_url,omitempty"` MetadataXml *string `json:"metadata_xml,omitempty"` NameIdFormat *string `json:"name_id_format,omitempty"` @@ -3421,7 +7059,7 @@ type OrganizationProjectsResponseProjectsStatus string // OrganizationResponseV1 defines model for OrganizationResponseV1. type OrganizationResponseV1 struct { // Id Deprecated: Use `slug` instead. - // Deprecated: + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set Id string `json:"id"` Name string `json:"name"` @@ -3431,9 +7069,31 @@ type OrganizationResponseV1 struct { // PgsodiumConfigResponse defines model for PgsodiumConfigResponse. type PgsodiumConfigResponse 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 + // PostgresConfigResponse defines model for PostgresConfigResponse. type PostgresConfigResponse struct { // CheckpointTimeout Default unit: s @@ -3520,7 +7180,7 @@ type ProjectUpgradeEligibilityResponse struct { LegacyAuthCustomRoles []string `json:"legacy_auth_custom_roles"` // ObjectsToBeDropped Use validation_errors instead. - // Deprecated: + // 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"` @@ -3529,11 +7189,11 @@ type ProjectUpgradeEligibilityResponse struct { } `json:"target_upgrade_versions"` // UnsupportedExtensions Use validation_errors instead. - // Deprecated: + // 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: + // 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 []ProjectUpgradeEligibilityResponse_ValidationErrors_Item `json:"validation_errors"` Warnings []ProjectUpgradeEligibilityResponse_Warnings_Item `json:"warnings"` @@ -3901,7 +7561,7 @@ type SnippetListDataVisibility string type SnippetResponse struct { Content struct { // Favorite Deprecated: Rely on root-level favorite property instead. - // Deprecated: + // 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"` Sql string `json:"sql"` @@ -4303,7 +7963,7 @@ type UpdateBranchBody struct { RequestReview *bool `json:"request_review,omitempty"` // ResetOnPush This field is deprecated and will be ignored. Use v1-reset-a-branch endpoint directly instead. - // Deprecated: + // 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"` } @@ -4372,6 +8032,7 @@ type UpdateJitAccessBody struct { // UpdatePgsodiumConfigBody defines model for UpdatePgsodiumConfigBody. type UpdatePgsodiumConfigBody struct { + // RootKey The pgsodium root key: 32 bytes, hex-encoded (64 characters). RootKey string `json:"root_key"` } @@ -4434,10 +8095,10 @@ type UpdatePostgresConfigBodySessionReplicationRole string 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"` + 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"` @@ -4455,21 +8116,19 @@ type UpdateProviderResponse struct { Domains *[]struct { CreatedAt *string `json:"created_at,omitempty"` Domain *string `json:"domain,omitempty"` - Id string `json:"id"` 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"` + 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"` - Id string `json:"id"` MetadataUrl *string `json:"metadata_url,omitempty"` MetadataXml *string `json:"metadata_xml,omitempty"` NameIdFormat *string `json:"name_id_format,omitempty"` @@ -4676,25 +8335,25 @@ type V1CreateProjectBody struct { HighAvailability *bool `json:"high_availability,omitempty"` // KpsEnabled This field is deprecated and is ignored in this request - // Deprecated: + // 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: + // 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: + // 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: + // 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. @@ -4991,14 +8650,14 @@ type V1ProjectResponse struct { CreatedAt string `json:"created_at"` // Id Deprecated: Use `ref` instead. - // Deprecated: + // 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: + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set OrganizationId string `json:"organization_id"` // OrganizationSlug Organization slug @@ -5034,14 +8693,14 @@ type V1ProjectWithDatabaseResponse struct { } `json:"database"` // Id Deprecated: Use `ref` instead. - // Deprecated: + // 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: + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set OrganizationId string `json:"organization_id"` // OrganizationSlug Organization slug @@ -5101,7 +8760,7 @@ type V1ServiceHealthResponse struct { Error *string `json:"error,omitempty"` // Healthy Deprecated. Use `status` instead. - // Deprecated: + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set Healthy bool `json:"healthy"` Info *V1ServiceHealthResponse_Info `json:"info,omitempty"` Name V1ServiceHealthResponseName `json:"name"` @@ -5124,7 +8783,7 @@ type V1ServiceHealthResponseInfo1 struct { DbConnected bool `json:"db_connected"` // Healthy Deprecated. Use `status` instead. - // Deprecated: + // 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"` } @@ -5218,6 +8877,9 @@ type VanitySubdomainConfigResponse struct { // VanitySubdomainConfigResponseStatus defines model for VanitySubdomainConfigResponse.Status. type VanitySubdomainConfigResponseStatus string +// bearerContextKey is the context key for bearer security scheme +type bearerContextKey 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). @@ -5835,9 +9497,11 @@ func (a GetProjectDbMetadataResponse_Databases_Item) MarshalJSON() ([]byte, erro return nil, fmt.Errorf("error marshaling 'name': %w", err) } - object["schemas"], err = json.Marshal(a.Schemas) - if err != nil { - return nil, fmt.Errorf("error marshaling 'schemas': %w", err) + if a.Schemas != nil { + object["schemas"], err = json.Marshal(a.Schemas) + if err != nil { + return nil, fmt.Errorf("error marshaling 'schemas': %w", err) + } } for fieldName, field := range a.AdditionalProperties { diff --git a/apps/cli-go/pkg/config/pgdelta_version.go b/apps/cli-go/pkg/config/pgdelta_version.go index 26be07f8a0..a38641ae7c 100644 --- a/apps/cli-go/pkg/config/pgdelta_version.go +++ b/apps/cli-go/pkg/config/pgdelta_version.go @@ -4,7 +4,7 @@ import "strings" // DefaultPgDeltaNpmVersion is the npm dist-tag/version used for @supabase/pg-delta // when supabase/.temp/pgdelta-version is absent or empty. -const DefaultPgDeltaNpmVersion = "1.0.0-alpha.27" +const DefaultPgDeltaNpmVersion = "1.0.0-alpha.32" const pgDeltaNpmVersionPlaceholder = "1.0.0-alpha.20" diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index ec7e643391..19f01f35b7 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -3,17 +3,17 @@ FROM supabase/postgres:17.6.1.143 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit -FROM postgrest/postgrest:v14.14 AS postgrest +FROM postgrest/postgrest:v14.15 AS postgrest FROM supabase/postgres-meta:v0.96.6 AS pgmeta -FROM supabase/studio:2026.07.06-sha-66cf431 AS studio +FROM supabase/studio:2026.07.13-sha-b5ada96 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.2 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor -FROM supabase/gotrue:v2.192.0 AS gotrue -FROM supabase/realtime:v2.112.6 AS realtime -FROM supabase/storage-api:v1.62.5 AS storage -FROM supabase/logflare:1.46.0 AS logflare +FROM supabase/gotrue:v2.193.0 AS gotrue +FROM supabase/realtime:v2.113.4 AS realtime +FROM supabase/storage-api:v1.66.4 AS storage +FROM supabase/logflare:1.47.1 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod index 3de3c61ae8..f05555c352 100644 --- a/apps/cli-go/pkg/go.mod +++ b/apps/cli-go/pkg/go.mod @@ -20,13 +20,13 @@ require ( 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.4.2 + github.com/oapi-codegen/runtime v1.6.0 github.com/spf13/afero v1.15.0 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/jsonc v0.3.3 - golang.org/x/mod v0.37.0 - google.golang.org/grpc v1.81.1 + golang.org/x/mod v0.38.0 + google.golang.org/grpc v1.82.1 ) require ( @@ -51,8 +51,8 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum index 8394b09a98..afa34b3b68 100644 --- a/apps/cli-go/pkg/go.sum +++ b/apps/cli-go/pkg/go.sum @@ -132,8 +132,8 @@ github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy 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.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE= -github.com/oapi-codegen/runtime v1.4.2/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= +github.com/oapi-codegen/runtime v1.6.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= @@ -217,15 +217,15 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= 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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= 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= @@ -255,8 +255,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc 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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.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= @@ -272,8 +272,8 @@ 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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= @@ -290,8 +290,8 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T 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.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index c30465cd85..d4830ccc9e 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -90,14 +90,14 @@ Always check `src/shared/` before writing new infrastructure. Do not duplicate w Also check the following `legacy/` infrastructure before writing equivalent helpers from scratch: -| Path | What it provides | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | -| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → linked-project.json → config fallback chain; matches Go's resolver order | -| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | -| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `~/.supabase//linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | -| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | -| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | +| Path | What it provides | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | +| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → `supabase/.temp/project-ref` file → prompt; matches Go's resolver order | +| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | +| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `/supabase/.temp/linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | +| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | +| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | --- @@ -237,6 +237,10 @@ Concrete examples worth watching for as more commands land: This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow. +### `Config.Validate` parity has one home + +Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go:989-1190`) is ported exactly once: `src/legacy/shared/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a Go validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. + --- ## Legacy Port: Go CLI Output Parity @@ -281,12 +285,19 @@ The legacy shell sends the same PostHog events to the same product analytics pip - **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits Go-shape properties: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`. - **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""` to match Go. - **Use `safeFlags: ["flag-name"]`** to whitelist flags that Go marks with `markFlagTelemetrySafe` (grep `apps/cli-go/cmd/*.go`). Today these are `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). +- **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe, mirroring Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks `flag.Value.(*utils.EnumFlag)` unconditionally — no per-flag `safeFlags` entry needed, and it stays correct as choices are added or removed. A command's own `config` only ever contains its own locally-declared flags, so this cannot cover the 3 global choice flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) — those are handled separately, see below. +- **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record, mirroring Go's `changedFlags()` walking `cmd.Parent()`'s `PersistentFlags()` (`cmd/root_analytics.go:53-76`). No per-command wiring needed. This gives two flag families their real value automatically, via the existing boolean-is-safe rule and a new choice-is-safe rule (`GLOBAL_CHOICE_FLAG_NAMES` — CLI-1904) respectively: + - Boolean globals: `--debug`, `--yes`, `--experimental`, `--create-ticket`. + - Choice globals: `--output`, `--dns-resolver`, `--agent`. + + Both rules apply ONLY when a command's own `flags` record doesn't already declare that CLI name — a command's own flag always wins. Example: `db diff` declares its own local `output: Flag.string("output")` (a file path, not a choice) in its `flags` record, so `db diff --output diff.sql` stays redacted — matching Go, where `isEnumFlag` type-asserts `db diff`'s own non-enum local flag object instead of root's persistent `*utils.EnumFlag`. + - **Proxy handlers (`LegacyGoProxy.exec`) must NOT wrap with any instrumentation.** The Go subprocess fires its own telemetry; a TS wrapper would double-count `cli_command_executed`. - **When promoting a command from proxy to native, reproduce every `phtelemetry.*` call in the Go counterpart.** Grep `apps/cli-go/internal//` for `service.Capture`, `service.Alias`, `service.Identify`, `service.GroupIdentify`, and `TrackUpgradeSuggested`. The current Go custom events that legacy ports must reproduce when natively ported: | Command | Event | Identity / groups | Go source | | ------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | - | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` + `analytics.identify(gotrueId)` after token persists | `internal/login/login.go:283-296` | + | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` | | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` | | `start` | `cli_stack_started` | none — fired after stack health check passes | `internal/start/start.go:1245` | | `sso/{list,create,update,remove}`, `branches/{create,update}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch | 7 call-sites under `internal/{sso,branches}/` | @@ -402,6 +413,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - `*.unit.test.ts` belongs to the `unit` Vitest project and is the default for unit-style and other fast in-process tests. - `*.integration.test.ts` belongs to the `integration` project and is for in-process integration tests that exercise real handler or service behavior with layered dependency replacement. - `*.e2e.test.ts` belongs to the `e2e` Vitest project and is for black-box CLI subprocess tests. +- `*.live.test.ts` belongs to the `live` Vitest project and is for black-box CLI subprocess tests that run against a **real, running Supabase platform or local Docker stack** — see "Live tests" below. ### Testing policy @@ -416,6 +428,21 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - Keep `*.e2e.test.ts` focused on golden paths, CLI surface behavior, and subprocess correctness, not branch-by-branch coverage. - **Forbidden pattern (do not add):** spawning the CLI to assert that `--help` renders a flag. Help text is dynamic over flag wiring and is exercised by the integration test's flag parser. The two backups e2e files removed alongside this guidance update are the canonical example of what not to write. +### Live tests (`*.live.test.ts`) + +Live tests are black-box CLI subprocess tests — like `*.e2e.test.ts`, but run against a **real backend** instead of local fakes/mocks: either the real Management API (a full [supabox](https://github.com/supabase/supabox) platform stack) or a real local Docker dev stack (`supabase start`'s actual containers). They are the highest-fidelity, most expensive tier — reserved for the small set of behaviors that only a genuinely running backend can prove (auth round-trips, real Docker label filtering, real container lifecycle), not for anything an integration test can already cover with mocks. + +- **Where they run:** authored in this repo, but executed by the [`supabase/cli-e2e-ci`](https://github.com/supabase/cli-e2e-ci) harness, which builds this CLI, brings up a full supabox stack (and has a real Docker daemon, since that's how supabox itself runs), and invokes the `live` Vitest project (`nx run-many -t test:live`). They never run as part of the default unit/integration/e2e loop, and locally they no-op unless the live environment is configured (see below) — there is no need to stand up supabox yourself to develop other code. +- **Add one whenever you add or change a command whose correctness genuinely depends on a real backend** — a new Management API command, or a change to `start`/`stop`/`status`'s real Docker interaction. Colocate it with the command, same as `*.e2e.test.ts`: `src/legacy/commands//[/].live.test.ts`. +- **Gating:** every live suite must be wrapped in one of `tests/helpers/live.ts`'s `describe.skipIf` gates so the file is inert (skipped, not failed) outside the cli-e2e-ci runner: + - `describeLive` — runs whenever `SUPABASE_ACCESS_TOKEN` is set (the live env is configured at all). Reuse this even for commands that don't call the Management API themselves (e.g. `stop`/`status`) — it doubles as the "we're in the full cli-e2e-ci runner, which also has a real Docker daemon" signal, and there is no dedicated Docker-availability gate today. + - `describeLiveProject` — additionally requires a provisioned project (`SUPABASE_LIVE_PROJECT_REF`); use for project-scoped Management API commands (branches, functions, project-scoped db). + - `describeLiveDataPlane` — additionally requires the project's own Postgres instance to be `ACTIVE_HEALTHY`; use for commands that talk to the project's data plane (migration, db, storage). +- **Invocation:** use `runSupabaseLive(args, options?)` (wraps `runSupabase` with the `legacy` entrypoint and the live profile/timeout defaults) rather than calling `runSupabase` directly, so every live test picks up the same environment plumbing. +- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real Go-schema `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example. +- **Keep the suite small and golden-path only** — same philosophy as `*.e2e.test.ts`, but even more so given the cost of a real backend. One or two scenarios per command is normal; branch-by-branch coverage belongs in `*.integration.test.ts`. +- Timeouts are generous by default (`testTimeout`/`hookTimeout: 300_000` for the whole `live` project) because real platform/Docker operations are slow — pass an explicit per-`test()` timeout when a scenario needs less (or, for a real local-stack `start`, close to the full budget). + --- ## Go CLI Parity Tracking diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 42d783057c..3eeb785c83 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -80,51 +80,51 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | -| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | -| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | -| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | -| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | -| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | -| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | -| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | -| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | -| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | +| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | +| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | +| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets including decrypted `encrypted:` values; `--dry-run`; best-effort pg-delta migrations-catalog cache (warning-only on failure). | +| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path native (drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override). Local path native: running check, recreate + migrate + seed via the hidden Go `db __db-bootstrap` seam, storage-gated bucket seeding (reuses `seed buckets`), git-branch `Finished…` line. Only the niche `--experimental` remote schema-files path still delegates to the Go binary (telemetry-disabled). | +| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Native TS port. Validates config, checks "already running" (prints Go's line), else delegates the container bootstrap (create + health + initial schema/roles/migrations/seed + `_current_branch`) to the hidden Go `db __db-bootstrap --mode start` seam. No status table / `cli_stack_started` (those are `supabase start`). `--from-backup` supported. | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | +| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | +| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | +| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | +| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | +| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | +| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | +| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | ## Code Generation @@ -211,111 +211,111 @@ Legend: - `wrapped`: Phase 0 proxy wrapper exists in the legacy shell - `missing`: no legacy shell command yet -| Command | Legacy status | Legacy command path | -| -------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | -| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | -| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | -| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | -| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | -| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | -| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | -| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | -| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | -| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | -| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | -| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | -| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | -| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | -| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) - native; non-TypeScript project refs use pg-meta with IPv4 pooler retry instead of Go's "Try using --db-url" failure; `--swift-access-control` requires `--lang swift`; explicit source flags with `--query-timeout` on the remote TypeScript path error, while the implicit linked TypeScript path warns and continues | -| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | -| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | -| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | -| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | -| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | -| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | -| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | -| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | -| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | -| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | -| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | -| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | -| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | -| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| Command | Legacy status | Legacy command path | +| -------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | +| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | +| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | +| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `start` | `ported` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) — native; orchestrates the 14-container local dev stack via direct Docker/Podman subprocess spawning (no Docker Compose), mirroring Go's sequential per-container `DockerStart`. Edge Runtime container bring-up, the fresh-volume DB schema/migration/seed setup pipeline, and fresh-volume storage bucket seeding are all implemented; only the linked-project version-check suggestion is out of scope for this port (tracked follow-up). | +| `stop` | `ported` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `status` | `ported` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | +| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | +| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | +| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | +| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | +| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | +| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | +| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | +| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | +| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | +| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | +| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | +| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | +| `db reset` | `ported` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | +| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | +| `db start` | `ported` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | +| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | +| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | +| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | +| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | +| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | +| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | +| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | Flag divergences from the Go reference: @@ -323,3 +323,18 @@ Flag divergences from the Go reference: `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. + +Behavioral divergences from the Go reference: + +- `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` 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/legacy/commands/services/SIDE_EFFECTS.md). diff --git a/apps/cli/package.json b/apps/cli/package.json index c184c44d66..b04c7fab9d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,9 +43,9 @@ "jose": "^6.2.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.196", - "@anthropic-ai/sdk": "^0.107.0", - "@clack/prompts": "^1.6.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.207", + "@anthropic-ai/sdk": "^0.111.0", + "@clack/prompts": "^1.7.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", "@effect/sql-pg": "catalog:", @@ -76,10 +76,10 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.22.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.38.8", + "posthog-node": "^5.41.0", "react": "^19.2.7", "react-devtools-core": "^7.0.1", - "semantic-release": "^25.0.5", + "semantic-release": "^25.0.6", "smol-toml": "^1.7.0", "tldts": "catalog:", "vitest": "catalog:", diff --git a/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md b/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md index a27b7f3ab6..9ab7ef9ade 100644 --- a/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md +++ b/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md @@ -61,7 +61,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 2d78ea2ec8..ffddf8b017 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Redacted, Result } from "effect"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts"; @@ -92,7 +92,7 @@ const tryKeyringDelete = ( deleted = true; } - if (platform === "win32" && readGoWindowsTarget(module, account)) { + if (platform === "win32" && probeWindowsTarget(module, account) !== "absent") { deleted = deleteGoWindowsTarget(module, account) || deleted; } @@ -123,6 +123,45 @@ function readGoWindowsTarget(module: KeyringModule, account: string): string | n } } +// `Entry.withTarget` is avoided as a probe — its constructor writes an empty +// placeholder. A `findCredentials` throw is ambiguous (an undecodable Go blob or +// a real enumeration failure), so reported as `"unknown"`, never assumed present. +type WindowsTargetProbe = "present" | "absent" | "unknown"; + +function probeWindowsTarget(module: KeyringModule, account: string): WindowsTargetProbe { + try { + const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); + // An empty password is an orphaned placeholder, not a real credential. + const credential = credentials.find( + (item) => item.account === account && item.password.length > 0, + ); + return credential ? "present" : "absent"; + } catch { + return "unknown"; + } +} + +// Constructing `withTarget` always leaves something at that target — the real +// credential, or the placeholder — so a delete that follows is never a +// legitimate "nothing to delete": any non-success is surfaced. +const deleteProbedWindowsTarget = ( + module: KeyringModule, + account: string, + onFailure: (cause: unknown) => E, +): Effect.Effect => + Effect.gen(function* () { + const result = yield* Effect.try(() => + module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ).deleteCredential(), + ).pipe(Effect.result); + if (Result.isSuccess(result) && result.success) return true; + const cause = Result.isFailure(result) ? result.failure : "credential was not removed"; + return yield* Effect.fail(onFailure(cause)); + }); + function normalizeGoWindowsPassword(value: string): string { const direct = normalizeKeyringToken(value); if (LEGACY_ACCESS_TOKEN_PATTERN.test(direct)) return direct; @@ -172,11 +211,10 @@ function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean // (backend unavailable) and only surfaces other errors (`unlink.go:36-40`). // // The plain `Entry(service, projectRef)` is the macOS/Linux form and the Windows -// default. On Windows, Go also writes a separate target-shaped credential; it is -// detected via `findCredentials` (a plain `getPassword` does not read the Go -// target reliably) and deleted through the `withTarget` entry. The `withTarget` -// entry is only constructed on Windows — on macOS its first argument is an -// invalid keychain domain and throws. +// default. On Windows, Go also writes a separate target-shaped credential, +// deleted through the `withTarget` entry. The `withTarget` entry is only +// constructed on Windows — on macOS its first argument is an invalid keychain +// domain and throws. // // Each entry is probed before `deleteCredential()`: on macOS deleting an absent // entry blocks on a Keychain authorization prompt, and an absent read means @@ -204,22 +242,16 @@ const deleteKeyringEntryStrict = ( deleted = true; } - if (platform === "win32" && readGoWindowsTarget(module, account)) { - const target = module.Entry.withTarget( - goWindowsCredentialTarget(account), - KEYRING_SERVICE, + if (platform === "win32" && probeWindowsTarget(module, account) !== "absent") { + const removed = yield* deleteProbedWindowsTarget( + module, account, - ); - yield* Effect.try({ - try: () => { - target.deleteCredential(); - }, - catch: (cause) => + (cause) => new LegacyCredentialDeleteError({ message: `failed to delete project credential: ${String(cause)}`, }), - }); - deleted = true; + ); + deleted ||= removed; } return deleted; @@ -256,43 +288,48 @@ const deleteProfileKeyringEntry = ( found = true; } - if (platform === "win32" && readGoWindowsTarget(module, account)) { - const target = module.Entry.withTarget( - goWindowsCredentialTarget(account), - KEYRING_SERVICE, + if (platform === "win32" && probeWindowsTarget(module, account) !== "absent") { + const removed = yield* deleteProbedWindowsTarget( + module, account, - ); - yield* Effect.try({ - try: () => { - target.deleteCredential(); - }, - catch: (cause) => + (cause) => new LegacyDeleteTokenError({ message: `failed to delete access token from keyring: ${String(cause)}`, }), - }); - found = true; + ); + found ||= removed; } return found ? "deleted" : "notFound"; }); -// Best-effort wipe of every entry in the `"Supabase CLI"` keyring namespace — -// the project database-password credentials `link` writes. Mirrors Go's -// `keyring.DeleteAll(namespace)` (`store.go:71`). Never fails: per-entry delete -// errors are swallowed so a single stuck credential can't abort logout. +// Best-effort wipe of the `"Supabase CLI"` keyring namespace, mirroring Go's +// `keyring.DeleteAll` (`store.go:71`); errors are swallowed so one stuck +// credential can't abort logout, matching Go's own handling (`logout.go:35`). // -// On Windows, Go stores credentials under the target-shaped name -// `Supabase CLI:` rather than the plain `Entry(service, account)` form -// (see `writeGoWindowsTarget`). So each discovered account is deleted in BOTH -// forms — the plain entry and, on win32, the Go target entry — mirroring the -// individual deletes in `deleteProfileKeyringEntry`. Without this, a Go-written -// project credential would survive `logout` on Windows. +// Go writes Windows credentials under the target-shaped form +// (`Supabase CLI:`), invisible to the plain enumeration below, so +// it's swept separately. `findCredentials` decodes every matched blob and +// aborts entirely on one undecodable entry, unlike Go's raw-byte `wincred.List`. const deleteAllKeyringEntries = ( module: KeyringModule, platform: RuntimePlatform, ): Effect.Effect => Effect.sync(() => { + if (platform === "win32") { + try { + const entries = module.findCredentials( + KEYRING_SERVICE, + `${goWindowsCredentialTarget("")}*`, + ); + for (const { account } of entries) { + deleteGoWindowsTarget(module, account); + } + } catch { + // best-effort + } + } + let entries: ReadonlyArray<{ account: string }>; try { entries = module.findCredentials(KEYRING_SERVICE); @@ -305,9 +342,6 @@ const deleteAllKeyringEntries = ( } catch { // best-effort per entry } - if (platform === "win32" && readGoWindowsTarget(module, account)) { - deleteGoWindowsTarget(module, account); - } } }); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 06dad317de..f5320dba28 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -32,24 +32,29 @@ let throwOnSetPassword = false; let throwOnSetSecret = false; const throwOnGetPasswordAccounts = new Set(); const throwOnDeleteAccounts = new Set(); +const failDeleteAccounts = new Set(); const withTargetCalls: string[] = []; +const opaqueAccounts = new Set(); +let throwOnFindCredentials = false; vi.mock("@napi-rs/keyring", () => ({ - findCredentials: (service: string, target?: string) => - Array.from(passwords.entries()) - .filter(([key]) => - // No target → model the Windows `CredEnumerate("*")` sweep, - // which matches both the plain (`/…`) and the Go target-shaped - // (`:/…`) entries by the leading segment. With a - // target → narrow to that specific Go target (used by readGoWindowsTarget). - target === undefined - ? key.split("/")[0]!.startsWith(service) - : key.startsWith(`${target}/`), - ) - .map(([key, password]) => ({ - account: key.split("/").at(-1)!, - password, - })), + findCredentials: (service: string, target?: string) => { + if (throwOnFindCredentials) throw new Error("CredEnumerate failed"); + const targetName = (key: string) => key.split("/")[0]!; + const matchesFilter = (key: string): boolean => { + if (target === undefined) return targetName(key) === service; + if (target.endsWith("*")) return targetName(key).startsWith(target.slice(0, -1)); + return targetName(key) === target; + }; + const matches = Array.from(passwords.entries()).filter(([key]) => matchesFilter(key)); + if (matches.some(([key]) => opaqueAccounts.has(key))) { + throw new Error("BadEncoding: invalid UTF-8 data in secure storage"); + } + return matches.map(([key, password]) => ({ + account: key.split("/").at(-1)!, + password, + })); + }, Entry: class Entry { service: string; account: string; @@ -61,7 +66,9 @@ vi.mock("@napi-rs/keyring", () => ({ } static withTarget(target: string, service: string, account: string) { withTargetCalls.push(`${target}/${service}/${account}`); - return new this(service, account, target); + const entry = new this(service, account, target); + if (!passwords.has(entry.key())) passwords.set(entry.key(), ""); + return entry; } key(): string { return this.target === undefined @@ -86,6 +93,7 @@ vi.mock("@napi-rs/keyring", () => ({ deleteCredential(): boolean { const key = this.key(); if (throwOnDeleteAccounts.has(key)) throw new Error("Keyring delete failed"); + if (failDeleteAccounts.has(key)) return false; if (!passwords.has(key)) throw new Error("not found"); passwords.delete(key); return true; @@ -140,6 +148,9 @@ beforeEach(() => { throwOnGetPasswordAccounts.clear(); throwOnDeleteAccounts.clear(); withTargetCalls.length = 0; + opaqueAccounts.clear(); + failDeleteAccounts.clear(); + throwOnFindCredentials = false; tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-")); }); @@ -337,7 +348,7 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { return Effect.gen(function* () { const { saveAccessToken } = yield* LegacyCredentials; yield* saveAccessToken(VALID_TOKEN); - expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.get(goWindowsKey("supabase")) ?? "").toBe(""); expect(passwords.has("Supabase CLI/supabase")).toBe(false); const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); expect(content).toBe(VALID_TOKEN); @@ -423,6 +434,31 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { }, ); + it.effect("win32: no Go target credential → LegacyNotLoggedInError, nothing fabricated", () => { + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + } + expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(withTargetCalls).toEqual([]); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + + it.effect("win32: an empty placeholder Go target → LegacyNotLoggedInError", () => { + passwords.set(goWindowsKey("supabase"), ""); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + } + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + it.effect( "keyring unavailable (SUPABASE_NO_KEYRING) with token in file → removes file, still NotLoggedIn", () => { @@ -521,6 +557,61 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); + it.effect( + "win32: deletes a Go target whose blob getPassword/findCredentials can't decode", + () => { + passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + opaqueAccounts.add(goWindowsKey("supabase")); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + yield* deleteAccessToken; + expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); + + it.effect( + "win32: a confirmed Go target whose delete returns false → LegacyDeleteTokenError", + () => { + passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + failDeleteAccounts.add(goWindowsKey("supabase")); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + } + expect(passwords.has(goWindowsKey("supabase"))).toBe(true); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); + + it.effect("win32: an enumeration hiccup alone does not block logout", () => { + throwOnFindCredentials = true; + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Success"); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + + it.effect( + "win32: an enumeration failure whose target also fails to delete → LegacyDeleteTokenError", + () => { + throwOnFindCredentials = true; + failDeleteAccounts.add(goWindowsKey("supabase")); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + } + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); + it.effect("legacy-keyring delete error is swallowed and does not change the outcome", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); @@ -554,26 +645,71 @@ describe("legacyCredentialsLayer.deleteAllProjectCredentials", () => { }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_NO_KEYRING: "1" } }))); }); + it.effect("never fails even when an individual delete throws", () => { + passwords.set("Supabase CLI/abcdefghijklmnopqrs1", "secret-1"); + throwOnDeleteAccounts.add("Supabase CLI/abcdefghijklmnopqrs1"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAllProjectCredentials); + expect(exit._tag).toBe("Success"); + }).pipe(Effect.provide(makeLayer())); + }); + it.effect("win32: deletes Go target-shaped project credentials", () => { - // Go stores Windows project credentials under `Supabase CLI:`, not the - // plain `Supabase CLI/` form. The sweep must delete the target form too. passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); + passwords.set(goWindowsKey("abcdefghijklmnopqrs2"), "secret-2"); return Effect.gen(function* () { const { deleteAllProjectCredentials } = yield* LegacyCredentials; yield* deleteAllProjectCredentials; expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); - it.effect("never fails even when an individual delete throws", () => { - passwords.set("Supabase CLI/abcdefghijklmnopqrs1", "secret-1"); - throwOnDeleteAccounts.add("Supabase CLI/abcdefghijklmnopqrs1"); + it.effect( + "win32: a single undecodable entry aborts the Go target sweep without failing logout", + () => { + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); + passwords.set(goWindowsKey("abcdefghijklmnopqrs2"), "secret-2"); + opaqueAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAllProjectCredentials); + expect(exit._tag).toBe("Success"); + // One undecodable entry aborts the whole findCredentials call. + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(true); + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(true); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); +}); + +describe("legacyCredentialsLayer.deleteProjectCredential", () => { + it.effect("win32: deletes a Go target-shaped project credential", () => { + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "db-password"); return Effect.gen(function* () { - const { deleteAllProjectCredentials } = yield* LegacyCredentials; - const exit = yield* Effect.exit(deleteAllProjectCredentials); - expect(exit._tag).toBe("Success"); - }).pipe(Effect.provide(makeLayer())); + const { deleteProjectCredential } = yield* LegacyCredentials; + const deleted = yield* deleteProjectCredential("abcdefghijklmnopqrs1"); + expect(deleted).toBe(true); + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); + + it.effect( + "win32: a confirmed project credential whose delete fails → LegacyCredentialDeleteError", + () => { + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "db-password"); + throwOnDeleteAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); + return Effect.gen(function* () { + const { deleteProjectCredential } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteProjectCredential("abcdefghijklmnopqrs1")); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyCredentialDeleteError"); + } + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); }); // Suppress unused-import nag — referenced in JSDoc / used in assertions above. diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index df39fcba27..8e69052093 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -37,6 +37,7 @@ function mockCliConfig(opts: { apiUrl: opts.apiUrl ?? "https://api.supabase.com", projectHost: opts.projectHost ?? "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), projectId: Option.none(), diff --git a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts index d6625c88f7..15c124caa2 100644 --- a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts +++ b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts @@ -38,19 +38,18 @@ describe("legacy CLI agent output", () => { }); expect(exitCode).toBe(1); + // CLI-1901: the vendored effect CLI library's own duplicate JSON render + // (the old `{_tag:"Help"}` + `{_tag:"Error", error:{code:"ShowHelp"}}` + // pair on stdout, `{_tag:"Errors"}` on stderr) is gone. stdout carries + // exactly this repo's single Go-parity error line; the library's help + // doc is redirected to stderr instead of being dropped or duplicated. expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ _tag: "Help" }), expect.objectContaining({ _tag: "Error", - error: expect.objectContaining({ code: "ShowHelp" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([ - expect.objectContaining({ - _tag: "Errors", - errors: [expect.objectContaining({ code: "UnknownSubcommand" })], + error: expect.objectContaining({ code: "UnknownSubcommand" }), }), ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); }); test("keeps parse errors in text mode when --output-format=text is explicit", async () => { @@ -63,7 +62,9 @@ describe("legacy CLI agent output", () => { ); expect(exitCode).toBe(1); - expect(stdout).toContain("DESCRIPTION"); + // CLI-1901: the help doc no longer prints to stdout at all. + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); }); @@ -77,7 +78,8 @@ describe("legacy CLI agent output", () => { ); expect(exitCode).toBe(1); - expect(stdout).toContain("DESCRIPTION"); + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); }); @@ -92,18 +94,12 @@ describe("legacy CLI agent output", () => { expect(exitCode).toBe(1); expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ _tag: "Help" }), expect.objectContaining({ _tag: "Error", - error: expect.objectContaining({ code: "ShowHelp" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([ - expect.objectContaining({ - _tag: "Errors", - errors: [expect.objectContaining({ code: "UnknownSubcommand" })], + error: expect.objectContaining({ code: "UnknownSubcommand" }), }), ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); }); test("keeps built-in version and help in text mode for detected coding agents", async () => { diff --git a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md index f69b0219d6..cff8a366c1 100644 --- a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md @@ -31,7 +31,6 @@ | `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:` (Go parity — 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`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md index 8b08e2727b..768bb9b728 100644 --- a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md @@ -31,7 +31,6 @@ | `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:` (Go parity — 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`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index a6428d8f47..2a28897a48 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -46,13 +46,13 @@ leaking the change to the surrounding process. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------- | -------------------------------------------------- | --------- | -| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | -| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | -| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | -| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | -| `SUPABASE_API_URL`, `SUPABASE_PROFILE` | API host / profile | no | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------ | --------- | +| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | +| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | +| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | +| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | +| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | ## Exit Codes @@ -99,10 +99,11 @@ Human banners are suppressed; a single structured result is emitted: - **Interim Go-proxy delegation for migration push.** The push step shells out to the bundled Go binary (`db push --include-roles --include-seed`) until `db push` gets its own native port (separate Linear issue). The sub-step is **not** instrumentation-wrapped (the subprocess fires - its own push telemetry). Known divergence: `LegacyGoProxy.exec` propagates the exit code, so Go's - push backoff is **not** reproduced (single attempt) — to be restored when `db push` is natively - ported. (`LegacyGoProxy.exec` exits the process on a non-zero exit rather than returning a - failure, so the step cannot be wrapped in `Effect.retry`.) + its own push telemetry). Known divergence: Go's push backoff is **not** reproduced (single + attempt) — to be restored when `db push` is natively ported. (`LegacyGoProxy.exec` fails with a + typed `LegacyGoChildExitError` on a non-zero exit rather than exiting the process — CLI-1879 — so + the step COULD now be wrapped in `Effect.retry`; leaving that unimplemented here is a deliberate + scope decision for the native `db push` port, not a technical blocker.) - **DB password is forwarded on the same channel the user supplied it (CLI-1617).** The proxy must be called 1:1 with the user's input: a flag stays a flag, an env var stays an env var. So when the user passed `-p/--password`, the push sub-step receives `--password ` (flag → flag); when diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.retry.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.retry.ts index 6ea685fafe..7b8d1fe290 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.retry.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.retry.ts @@ -23,16 +23,15 @@ const MAX_INTERVAL = Duration.seconds(60); * - `MaxElapsedTime` 15m (intersected via `during`; in practice the 8-retry cap * always trips first, but reproduced for completeness) */ -export const legacyBootstrapBackoff: Schedule.Schedule<[Duration.Duration, Duration.Duration]> = - Schedule.exponential("3 seconds", 1.5).pipe( - Schedule.modifyDelay((_, delay) => Effect.succeed(Duration.min(delay, MAX_INTERVAL))), - Schedule.modifyDelay((_, delay) => - Random.next.pipe( - Effect.map((random) => Duration.millis(Duration.toMillis(delay) * (0.5 + random))), - ), +export const legacyBootstrapBackoff = Schedule.exponential("3 seconds", 1.5).pipe( + Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, MAX_INTERVAL))), + Schedule.modifyDelay(({ duration }) => + Random.next.pipe( + Effect.map((random) => Duration.millis(Duration.toMillis(duration) * (0.5 + random))), ), - Schedule.both(Schedule.during("15 minutes")), - ); + ), + Schedule.upTo({ duration: "15 minutes" }), +); /** * Reproduces Go's `utils.NewErrorCallback` (`internal/utils/retry.go:19-35`): after diff --git a/apps/cli/src/legacy/commands/branches/create/create.command.ts b/apps/cli/src/legacy/commands/branches/create/create.command.ts index 69367b3f4d..1cc85ecbce 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.command.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.command.ts @@ -44,7 +44,6 @@ const BRANCH_SIZES = [ "48xlarge_optimized_memory", "4xlarge", "8xlarge", - "nano", "small", "xlarge", ] as const; @@ -96,7 +95,7 @@ export const legacyBranchesCreateCommand = Command.make("create", config).pipe( Command.withShortDescription("Create a preview branch"), Command.withHandler((flags) => legacyBranchesCreate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index fcbb44aa0f..8fb2a9e5d5 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts @@ -1,8 +1,10 @@ import type { V1CreateABranchOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Option } from "effect"; +import { Command } from "effect/unstable/cli"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; import { LEGACY_VALID_REF, buildLegacyTestRuntime, @@ -13,7 +15,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import type { LegacyBranchesCreateFlags } from "./create.command.ts"; +import { legacyBranchesCreateCommand, type LegacyBranchesCreateFlags } from "./create.command.ts"; import { legacyBranchesCreate } from "./create.handler.ts"; type CreatedBranch = typeof V1CreateABranchOutput.Type; @@ -282,4 +284,44 @@ describe("legacy branches create integration", () => { expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + + // Go parity (`apps/cli-go/cmd/projects.go:34-55`, reused by `branches create` + // at `apps/cli-go/cmd/branches.go:212`): Go's --size EnumFlag is an 18-value + // list that does not include "nano" (or "pico") and rejects any other value + // at flag-parse time. TS previously listed "nano" as a valid choice, silently + // succeeding where Go errors. + it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { + const root = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyBranchesCreateCommand]), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(root, { version: "0.0.0-test" })(["create", "--size", "nano"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(rejectsInvalidSizeChoice(Cause.squash(exit.cause))).toBe(true); + } + }) as Effect.Effect; + }); }); + +// Distinguishes "the --size flag itself was rejected at parse time" from any +// other failure (e.g. a missing runtime service in this minimal test setup), +// so the regression test above can't pass for the wrong reason. +function rejectsInvalidSizeChoice(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("errors" in error)) return false; + const { errors } = error; + if (!Array.isArray(errors)) return false; + return errors.some( + (candidate: unknown) => + typeof candidate === "object" && + candidate !== null && + "_tag" in candidate && + candidate._tag === "InvalidValue" && + "option" in candidate && + candidate.option === "size", + ); +} diff --git a/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md index 09bab3fd8f..4983406350 100644 --- a/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md @@ -6,10 +6,10 @@ Same auth and project-ref resolution chain as every Management-API legacy comman ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves — Go parity | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------ | +| `/supabase/.temp/linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves — Go parity | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes @@ -51,4 +51,4 @@ In Go encoder modes, the header goes to stderr followed by the encoded payload o ## Notes -The upgrade-suggest call uses the parent project ref (resolved from `--project-ref`) rather than the branch's project ref. Both refs belong to the same organization, so the entitlement check returns the same `org_slug` either way; this also sidesteps a known API schema constraint where `getProject` strictly requires a `^[a-z]{20}$` ref. +The upgrade-suggest call uses the branch's own resolved project ref (`legacyResolveBranchProjectRef`), matching Go's `update.go:26` (`pause.GetBranchProjectRef`) — not the parent `--project-ref` value — so the entitlements check is scoped to the branch's org. diff --git a/apps/cli/src/legacy/commands/branches/update/update.command.ts b/apps/cli/src/legacy/commands/branches/update/update.command.ts index 205f2cdb28..2495a68601 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.command.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.command.ts @@ -52,7 +52,7 @@ export const legacyBranchesUpdateCommand = Command.make("update", config).pipe( Command.withShortDescription("Update a preview branch"), Command.withHandler((flags) => legacyBranchesUpdate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md index f25912c9c5..ed1b76d1c2 100644 --- a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md @@ -26,10 +26,10 @@ ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------ | -| `0` | success — completion script for the chosen shell printed to stdout | -| `1` | invocation error (missing or unknown shell subcommand) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------- | +| `0` | success — completion script for the chosen shell printed to stdout | +| `1` | unknown shell subcommand, or bare `completion` with no shell subcommand — **known divergence, see Notes (CLI-1906)** | ## Output @@ -58,9 +58,19 @@ straight to the Go binary. - Effect CLI's `--completions` global flag remains exposed at the root for `next/` users; it does not satisfy the legacy parity contract and is not what this subcommand routes through. -- The Go CLI exits non-zero when called without a shell subcommand (e.g. - `supabase completion`). Effect CLI surfaces the same condition through its usual - "missing subcommand" help-with-exit-1 behavior. +- **Known divergence (CLI-1906):** Go's cobra CLI exits `0` on both bare + `completion` (no shell subcommand) AND `completion ` — cobra + treats an unrecognized subcommand name the same as a missing one: a + non-`Runnable()` command with no `RunE` returns `flag.ErrHelp`, which cobra + maps to printing help and returning a nil error (verified against the + compiled Go binary for both cases: `completion` and `completion +bogus-shell` both exit `0`). The legacy TS shell currently exits `1` for + both invocations; this is a real, systemic exit-code bug in the shared CLI + harness (`shared/cli/run.ts`), not `completion`-specific — it reproduces on + any bare or unrecognized-subcommand invocation of a group command with + subcommands (e.g. `branches`, `branches bogus-subcommand`). See CLI-1906 for + the fix; this doc describes current (buggy) behavior, not the intended + target. - Each of `bash`/`zsh`/`fish`/`powershell` declares `--no-descriptions` (cobra's auto-registered flag, `completions.go` in `spf13/cobra`) and forwards it to the Go binary, so the emitted script omits completion descriptions exactly as it diff --git a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md index 8a15ea687b..3661eb0d7e 100644 --- a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md @@ -7,20 +7,21 @@ local → if changed, print the unified diff and confirm → PATCH/PUT/POST. ## Files Read -| Path | Format | When | -| ------------------------------------------------ | ------------------------- | --------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) | -| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | -| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per Go rules (see below) | -| `~/.supabase//linked-project.json` | JSON | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) | +| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` and to collect `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets | +| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per Go rules (see below) | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) | +| `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (mirrors Go's `ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism) | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | `Effect.ensuring` after run (success **and** failure), if ref resolved | -| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after run (success **and** failure) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `Effect.ensuring` after run (success **and** failure), if ref resolved | +| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after run (success **and** failure) | No writes to `config.toml`. @@ -50,14 +51,14 @@ when its local gate is off. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `SUPABASE_PROFILE` | API profile selection | no | -| `env(VAR)` references | interpolated into `config.toml` values at load | no | +| Variable | Purpose | Required? | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `env(VAR)` references | interpolated into `config.toml` values at load | no | +| `DOTENV_PRIVATE_KEY`, `DOTENV_PRIVATE_KEY_*` | decrypt `encrypted:` (dotenvx) secret values before hashing/pushing; comma-split, first matching key wins | only if a `config.Secret`-typed field (see below) holds an `encrypted:` value — an `encrypted:`-looking string in a non-secret field (e.g. an email template `subject`) never needs a key | ## Exit Codes @@ -65,6 +66,7 @@ when its local gate is off. | ---- | ------------------------------------------------------------------------------------------ | | `0` | success, **including** declining a confirmation prompt (Go returns nil and continues) | | `1` | malformed `config.toml` | +| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (see below) | | `1` | invalid `auth.email.*.content_path` (missing/unreadable template file when `auth.enabled`) | | `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | | `1` | list-addons failure (network or non-200) | @@ -115,5 +117,8 @@ keys mirror `config.toml` paths. - Diff bytes are byte-for-byte identical to the Go CLI (BurntSushi TOML encoder + anchored diff ports). - Optional `*pointer` sections (`db.ssl_enforcement`, `storage.image_transformation`, `storage.s3_protocol`) are decoded as defaulted-present by `@supabase/config`; their true presence is recovered from the raw (merged) config document so they are skipped when absent, matching Go's nil-pointer behaviour. - **`[remotes.*]` overrides are merged before push.** When a `[remotes.]` block declares `project_id == `, `@supabase/config` merges that block's subtree over the base config at the raw (pre-decode) level — Go's `mergeRemoteConfig` (`apps/cli-go/pkg/config/config.go:550`) — so only the keys the block declares override the base. `Loading config override: [remotes.]` prints to stderr. Two remotes sharing the target `project_id` abort with Go's `duplicate project_id for [remotes.] and [remotes.]` message. +- **`encrypted:` (dotenvx) secrets are decrypted, hashed, and pushed as plaintext**, matching Go's `Secret.Decrypt`/`DecryptSecretHookFunc`. `DOTENV_PRIVATE_KEY`(`_*`) values from the shell + `supabase/.env` decrypt the ciphertext; the decrypted plaintext is what gets hashed for the diff and sent as `Secret.Value` in the update body. The ciphertext itself is never pushed. +- **An undecryptable secret aborts before any network call.** Before the cost-matrix list-addons request or any other service call, every `config.Secret`-typed value in the document is asserted decryptable — not just `auth.*` (the only fields `config push` actually sends), matching Go's document-wide decode hook, which runs the same check regardless of which fields a given command reads. This covers `[db.vault]` (a `map[string]Secret` in Go too, not just an `auth.*` field). An undecryptable value aborts with Go's `failed to parse config: ` message, exit code `1`. +- **Deprecated `[auth.external.{linkedin,slack}]` secrets are checked before they're stripped.** `@supabase/config` strips these deprecated blocks from the decoded document before returning it, but Go's decrypt hook runs at decode time — before its own later `external.validate()` deletes them — so `config push` re-checks the stripped-out sub-objects separately, matching Go's decode-before-delete order rather than missing a secret hiding in one of them. - KNOWN GAPS: - - **`encrypted:` (dotenvx) secret decryption is not reproduced.** The Go CLI decrypts `encrypted:` values before hashing and pushes the plaintext; we cannot decrypt here. Rather than push the ciphertext (which would overwrite the remote secret with garbage), `encrypted:` values are treated as unresolved — exactly like `env()` refs: they hash to `""`, so the empty hash gates them out of both the diff and the update body and the remote secret is left untouched. + - The document-wide decrypt-or-abort pre-check scans the config document `@supabase/config` hands back, which has the _matched_ `[remotes.]` block already merged in and its `remotes` key removed. An `encrypted:` secret that's undecryptable inside a **different, non-matching** `[remotes.*]` block is therefore not caught here (Go's decode hook runs over the whole parsed document, including every remote, so it would abort in that case too). Narrow edge case: it only matters when a project declares multiple remotes and the _unused_ one has a broken secret. diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts index c555f72ff9..b1836729c6 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts @@ -2,6 +2,7 @@ import type { ProjectConfig } from "@supabase/config"; import { diff } from "./config-sync.diff.ts"; import { encodeToml, type TomlField, type TomlValue } from "./config-sync.toml.ts"; +import { legacyStrToArr } from "../../../../shared/legacy-local-config-values.ts"; import { intToUint } from "../../../../shared/legacy-size-units.ts"; /** @@ -56,11 +57,6 @@ export interface RemoteApiConfig { readonly max_rows: number; } -/** Go `strToArr`: empty string → `[]`, else comma-split (no trimming here). */ -function strToArr(v: string): Array { - return v.length === 0 ? [] : v.split(","); -} - /** Projects the loaded `config.api` into the push subset. */ export function apiSubsetFromConfig(config: ProjectConfig): ApiSubset { const api = config.api; @@ -94,8 +90,8 @@ function applyRemoteApiConfig(local: ApiSubset, remote: RemoteApiConfig): ApiSub return { ...local, enabled: true, - schemas: strToArr(remote.db_schema).map((s) => s.trim()), - extra_search_path: strToArr(remote.db_extra_search_path).map((s) => s.trim()), + schemas: legacyStrToArr(remote.db_schema).map((s) => s.trim()), + extra_search_path: legacyStrToArr(remote.db_extra_search_path).map((s) => s.trim()), max_rows: intToUint(remote.max_rows), }; } diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts index 2321f9d960..7a884c5da2 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts @@ -13,10 +13,11 @@ import type { ProjectConfig } from "@supabase/config"; import { diff } from "./config-sync.diff.ts"; import { type TomlField, type TomlValue, encodeToml } from "./config-sync.toml.ts"; +import { legacyStrToArr } from "../../../../shared/legacy-local-config-values.ts"; import { intToUint } from "../../../../shared/legacy-size-units.ts"; import { durationString, parseDuration, secondsToDurationString } from "./config-sync.duration.ts"; import type { AuthEmailContent } from "./config-sync.auth-email-content.ts"; -import { secretHash } from "./config-sync.secret.ts"; +import { secretHash, secretPlaintext } from "./config-sync.secret.ts"; // --------------------------------------------------------------------------- // Sub-types @@ -838,11 +839,6 @@ const AUTH_FIELDS: ReadonlyArray = [ // Helpers // --------------------------------------------------------------------------- -/** Go `strToArr`: empty string → `[]`, else comma-split. */ -function strToArr(v: string): Array { - return v.length === 0 ? [] : v.split(","); -} - /** Go `cast.IntToUint`: clamp negatives to 0. */ function valOrDefault(v: T | null | undefined, def: T): T { return v == null ? def : v; @@ -858,9 +854,13 @@ function normalizeDurationStr(s: string | undefined): string { } } -function projectSecret(projectId: string, value: string | undefined): string { +function projectSecret( + projectId: string, + value: string | undefined, + dotenvPrivateKeys: ReadonlyArray, +): string { if (!value) return ""; - return secretHash(projectId, value); + return secretHash(projectId, value, dotenvPrivateKeys); } /** @@ -908,12 +908,21 @@ export interface AuthPresence { * Projects `config.auth` into the push subset, pre-computing all duration and * secret fields. `projectId` is the HMAC key for secret hashing. `presence` * carries raw-config presence for the optional sub-sections Go skips when nil. + * `dotenvPrivateKeys` (Go's `DOTENV_PRIVATE_KEY`/`DOTENV_PRIVATE_KEY_*` values) + * decrypt any `encrypted:` (dotenvx) secret before it's hashed or copied into + * {@link AuthSubset.rawSecrets} — see `config-sync.secret.ts`. + * + * @throws When an `encrypted:` secret cannot be decrypted with any key. The + * handler's document-wide pre-check (`legacyAssertDecryptableSecrets`) runs + * before this and is expected to have already aborted in that case, so this + * throw is a defensive backstop, not the primary abort path. */ export function authSubsetFromConfig( config: ProjectConfig, projectId: string, presence: AuthPresence, emailContent: AuthEmailContent = { template: {}, notification: {} }, + dotenvPrivateKeys: ReadonlyArray = [], ): AuthSubset { const a = config.auth; @@ -947,7 +956,7 @@ export function authSubsetFromConfig( : { enabled: captchaConfig.enabled ?? false, provider: captchaConfig.provider ?? "", - secret: projectSecret(projectId, captchaConfig.secret ?? ""), + secret: projectSecret(projectId, captchaConfig.secret ?? "", dotenvPrivateKeys), }; // Hooks — Go gates each on `hook. != nil`; absent in raw config → skip. @@ -960,7 +969,7 @@ export function authSubsetFromConfig( return { enabled: hc.enabled, uri: hc.uri ?? "", - secrets: projectSecret(projectId, hc.secrets ?? ""), + secrets: projectSecret(projectId, hc.secrets ?? "", dotenvPrivateKeys), }; } const hook: AuthSubset["hook"] = { @@ -1047,7 +1056,7 @@ export function authSubsetFromConfig( host: smtpConfig.host ?? "", port: smtpConfig.port ?? 0, user: smtpConfig.user ?? "", - pass: projectSecret(projectId, smtpConfig.pass ?? ""), + pass: projectSecret(projectId, smtpConfig.pass ?? "", dotenvPrivateKeys), admin_email: smtpConfig.admin_email ?? "", sender_name: smtpConfig.sender_name ?? "", }; @@ -1064,7 +1073,7 @@ export function authSubsetFromConfig( enabled: tc.enabled, account_sid: tc.account_sid ?? "", message_service_sid: tc.message_service_sid ?? "", - auth_token: projectSecret(projectId, tc.auth_token ?? ""), + auth_token: projectSecret(projectId, tc.auth_token ?? "", dotenvPrivateKeys), }; } const sms: AuthSubset["sms"] = { @@ -1077,18 +1086,18 @@ export function authSubsetFromConfig( messagebird: { enabled: s.messagebird.enabled, originator: s.messagebird.originator ?? "", - access_key: projectSecret(projectId, s.messagebird.access_key ?? ""), + access_key: projectSecret(projectId, s.messagebird.access_key ?? "", dotenvPrivateKeys), }, textlocal: { enabled: s.textlocal.enabled, sender: s.textlocal.sender ?? "", - api_key: projectSecret(projectId, s.textlocal.api_key ?? ""), + api_key: projectSecret(projectId, s.textlocal.api_key ?? "", dotenvPrivateKeys), }, vonage: { enabled: s.vonage.enabled, from: s.vonage.from ?? "", api_key: s.vonage.api_key ?? "", - api_secret: projectSecret(projectId, s.vonage.api_secret ?? ""), + api_secret: projectSecret(projectId, s.vonage.api_secret ?? "", dotenvPrivateKeys), }, test_otp: s.test_otp ?? {}, }; @@ -1105,7 +1114,7 @@ export function authSubsetFromConfig( external[k] = { enabled: p.enabled, client_id: p.client_id ?? "", - secret: projectSecret(projectId, p.secret ?? ""), + secret: projectSecret(projectId, p.secret ?? "", dotenvPrivateKeys), url: p.url ?? "", redirect_uri: p.redirect_uri ?? "", skip_nonce_check: p.skip_nonce_check ?? false, @@ -1193,33 +1202,52 @@ export function authSubsetFromConfig( allow_dynamic_registration: a.oauth_server.allow_dynamic_registration, authorization_url_path: a.oauth_server.authorization_url_path, }, - publishable_key: projectSecret(projectId, a.publishable_key ?? ""), - secret_key: projectSecret(projectId, a.secret_key ?? ""), - jwt_secret: projectSecret(projectId, a.jwt_secret ?? ""), - anon_key: projectSecret(projectId, a.anon_key ?? ""), - service_role_key: projectSecret(projectId, a.service_role_key ?? ""), + publishable_key: projectSecret(projectId, a.publishable_key ?? "", dotenvPrivateKeys), + secret_key: projectSecret(projectId, a.secret_key ?? "", dotenvPrivateKeys), + jwt_secret: projectSecret(projectId, a.jwt_secret ?? "", dotenvPrivateKeys), + anon_key: projectSecret(projectId, a.anon_key ?? "", dotenvPrivateKeys), + service_role_key: projectSecret(projectId, a.service_role_key ?? "", dotenvPrivateKeys), third_party, // Raw plaintext secrets for the update body (see AuthRawSecrets). Sourced - // from the same config accessors used for hashing above. + // from the same config accessors used for hashing above, decrypted the + // same way (`secretPlaintext`) so an `encrypted:` value is never sent + // as-is — Go always pushes `Secret.Value` (plaintext), never the ciphertext. rawSecrets: { - captcha: captchaConfig?.secret ?? "", + captcha: secretPlaintext(captchaConfig?.secret ?? "", dotenvPrivateKeys), hooks: { - mfa_verification_attempt: h.mfa_verification_attempt?.secrets ?? "", - password_verification_attempt: h.password_verification_attempt?.secrets ?? "", - custom_access_token: h.custom_access_token?.secrets ?? "", - send_sms: h.send_sms?.secrets ?? "", - send_email: h.send_email?.secrets ?? "", - before_user_created: h.before_user_created?.secrets ?? "", + mfa_verification_attempt: secretPlaintext( + h.mfa_verification_attempt?.secrets ?? "", + dotenvPrivateKeys, + ), + password_verification_attempt: secretPlaintext( + h.password_verification_attempt?.secrets ?? "", + dotenvPrivateKeys, + ), + custom_access_token: secretPlaintext( + h.custom_access_token?.secrets ?? "", + dotenvPrivateKeys, + ), + send_sms: secretPlaintext(h.send_sms?.secrets ?? "", dotenvPrivateKeys), + send_email: secretPlaintext(h.send_email?.secrets ?? "", dotenvPrivateKeys), + before_user_created: secretPlaintext( + h.before_user_created?.secrets ?? "", + dotenvPrivateKeys, + ), }, - smtp_pass: smtpConfig?.pass ?? "", + smtp_pass: secretPlaintext(smtpConfig?.pass ?? "", dotenvPrivateKeys), sms: { - twilio: s.twilio.auth_token ?? "", - twilio_verify: s.twilio_verify.auth_token ?? "", - messagebird: s.messagebird.access_key ?? "", - textlocal: s.textlocal.api_key ?? "", - vonage: s.vonage.api_secret ?? "", + twilio: secretPlaintext(s.twilio.auth_token ?? "", dotenvPrivateKeys), + twilio_verify: secretPlaintext(s.twilio_verify.auth_token ?? "", dotenvPrivateKeys), + messagebird: secretPlaintext(s.messagebird.access_key ?? "", dotenvPrivateKeys), + textlocal: secretPlaintext(s.textlocal.api_key ?? "", dotenvPrivateKeys), + vonage: secretPlaintext(s.vonage.api_secret ?? "", dotenvPrivateKeys), }, - providers: Object.fromEntries(Object.entries(ext ?? {}).map(([k, p]) => [k, p.secret ?? ""])), + providers: Object.fromEntries( + Object.entries(ext ?? {}).map(([k, p]) => [ + k, + secretPlaintext(p.secret ?? "", dotenvPrivateKeys), + ]), + ), }, }; } @@ -1267,7 +1295,7 @@ export function applyRemoteAuthConfig(local: AuthSubset, remote: RemoteAuthConfi // Base scalar fields const siteUrl = valOrDefault(remote.site_url, ""); - const additionalRedirectUrls = strToArr(valOrDefault(remote.uri_allow_list, "")); + const additionalRedirectUrls = legacyStrToArr(valOrDefault(remote.uri_allow_list, "")); const jwtExpiry = intToUint(valOrDefault(remote.jwt_exp, 0)); const enableRefreshTokenRotation = valOrDefault(remote.refresh_token_rotation_enabled, false); const refreshTokenReuseInterval = intToUint( @@ -1290,7 +1318,7 @@ export function applyRemoteAuthConfig(local: AuthSubset, remote: RemoteAuthConfi webauthn = { rp_display_name: valOrDefault(remote.webauthn_rp_display_name, ""), rp_id: valOrDefault(remote.webauthn_rp_id, ""), - rp_origins: strToArr(valOrDefault(remote.webauthn_rp_origins, "")), + rp_origins: legacyStrToArr(valOrDefault(remote.webauthn_rp_origins, "")), }; } @@ -1754,7 +1782,7 @@ export function applyRemoteAuthConfig(local: AuthSubset, remote: RemoteAuthConfi /** Port of Go `sms.fromAuthConfig` → `envToMap`. */ function envToMap(input: string): Record { - const env = strToArr(input); + const env = legacyStrToArr(input); const result: Record = {}; for (const kv of env) { const eqIdx = kv.indexOf("="); diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts index 4083093f14..403cb01611 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts @@ -1418,10 +1418,11 @@ describe("authToUpdateBody secrets", () => { expect("security_captcha_secret" in body).toBe(false); }); - it("never pushes dotenvx ciphertext (encrypted: hashes to '' so the gate drops it)", () => { - // secretHash returns "" for `encrypted:` values, so the projected captcha - // secret is empty even though the raw (ciphertext) value is still present. - // The empty hash must gate the ciphertext out of the update body. + it("gates the raw secret out of the body when the hashed field is empty", () => { + // `authToUpdateBody` itself only knows the pre-computed hash/rawSecrets + // pair — this exercises the empty-hash gate directly (a decrypt failure + // for a real `encrypted:` value aborts earlier, in `authSubsetFromConfig` / + // the handler's document-wide pre-check; see push.integration.test.ts). const local = bareAuth({ enabled: true, captcha: { enabled: true, provider: "hcaptcha", secret: "" }, diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts index 7b00046e6f..130bf6c48e 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts @@ -5,33 +5,72 @@ * Rules: * - Empty value → "" (no hash prefix). * - Value matching `^env\((.*)\)$` (unresolved env reference) → "" (no hash). - * - Value starting with `encrypted:` (dotenvx ciphertext) → "" (no hash). + * - Value starting with `encrypted:` (dotenvx ciphertext) → decrypt with + * `legacyDecryptSecret`, then hash the decrypted plaintext. * - Otherwise → "hash:" + sha256Hmac(projectId, value). * - * NOTE: `encrypted:` dotenvx decryption is not implemented. The Go CLI decrypts - * such values before hashing and pushes the plaintext; we cannot. Rather than - * hash and push the ciphertext — which would silently overwrite the remote - * secret with garbage — we treat `encrypted:` values as unresolved, exactly like - * `env()` refs: `secretHash` returns "", so the empty hash gates the value out of - * both the diff and the update body and the remote secret is left untouched. - * This is a documented residual gap for local dev use of dotenvx secrets - * (see SIDE_EFFECTS.md). + * `config push`'s handler runs a document-wide decrypt-or-abort pre-check + * (`push.handler.ts`, reusing `legacyAssertDecryptableSecrets`) immediately + * after loading `config.toml` and before any network call — mirroring Go's + * `config.Load` timing, where `DecryptSecretHookFunc` runs as part of the + * generic decode hook chain, before `internal/config/push.Run` ever reads the + * cost matrix or any service. By the time the functions below run (deep in + * the auth service's local/diff computation), decryption is therefore + * expected to always succeed. They still throw a `failed to parse config: + * ` error (Go's own wording) rather than silently gating the secret + * out, in case that invariant is ever violated by a future field addition. */ import { createHmac } from "node:crypto"; +import { legacyDecryptSecret } from "../../../../shared/legacy-vault-decrypt.ts"; + const ENV_PATTERN = /^env\((.*)\)$/; const ENCRYPTED_PREFIX = "encrypted:"; const HASHED_PREFIX = "hash:"; +/** Decrypts `value` when it's a dotenvx `encrypted:` ciphertext; otherwise returns it unchanged. */ +function decryptIfNeeded(value: string, dotenvPrivateKeys: ReadonlyArray): string { + if (!value.startsWith(ENCRYPTED_PREFIX)) return value; + const decrypted = legacyDecryptSecret(value, dotenvPrivateKeys); + if (!decrypted.ok) { + throw new Error(`failed to parse config: ${decrypted.error}`); + } + return decrypted.value; +} + /** * Returns the TOML serialisation of a Secret field, mirroring Go's - * `Secret.MarshalText`. The project ref is the HMAC key. + * `Secret.MarshalText`. The project ref is the HMAC key. `dotenvPrivateKeys` + * are Go's `DOTENV_PRIVATE_KEY`/`DOTENV_PRIVATE_KEY_*` values + * (`legacyCollectDotenvPrivateKeys`), used to decrypt an `encrypted:` value + * before hashing — Go always hashes the decrypted plaintext, never the + * ciphertext. + * + * @throws When an `encrypted:` value cannot be decrypted with any key. */ -export function secretHash(projectId: string, value: string): string { +export function secretHash( + projectId: string, + value: string, + dotenvPrivateKeys: ReadonlyArray, +): string { if (value.length === 0) return ""; if (ENV_PATTERN.test(value)) return ""; - if (value.startsWith(ENCRYPTED_PREFIX)) return ""; - const hmac = createHmac("sha256", projectId).update(value).digest("hex"); + const plaintext = decryptIfNeeded(value, dotenvPrivateKeys); + const hmac = createHmac("sha256", projectId).update(plaintext).digest("hex"); return HASHED_PREFIX + hmac; } + +/** + * Resolves a Secret field to the plaintext Go sends as `Secret.Value` in the + * update-request body — decrypting an `encrypted:` value with + * `dotenvPrivateKeys`, otherwise returning `value` unchanged. Callers gate on + * {@link secretHash}'s result being non-empty before using this (mirrors Go's + * `if len(Secret.SHA256) > 0`), so the empty/unresolved-`env()` cases never + * reach the request body regardless of what this returns for them. + * + * @throws When an `encrypted:` value cannot be decrypted with any key. + */ +export function secretPlaintext(value: string, dotenvPrivateKeys: ReadonlyArray): string { + return decryptIfNeeded(value, dotenvPrivateKeys); +} diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts index 9766f1efbb..707ad52f28 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts @@ -1,6 +1,6 @@ /** * Unit tests for config-sync.secret.ts — golden parity with Go's - * `Secret.MarshalText` (`apps/cli-go/pkg/config/secret.go`). + * `Secret.MarshalText` + `DecryptSecretHookFunc` (`apps/cli-go/pkg/config/secret.go`). * * The HMAC keys/values below were captured from the same `createHmac` the port * uses; they lock the exact `hash:` serialisation Go emits. @@ -8,42 +8,86 @@ import { describe, expect, it } from "vitest"; -import { secretHash } from "./config-sync.secret.ts"; +import { secretHash, secretPlaintext } from "./config-sync.secret.ts"; + +// Go's test vector — `apps/cli-go/pkg/config/secret_test.go:9-19` (same one +// `legacy-vault-decrypt.unit.test.ts` uses). Decrypts to the plaintext "value". +const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; +const ENCRYPTED_VALUE = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; +const WRONG_KEY = "11".repeat(32); describe("secretHash", () => { it("returns the hash: form for a plaintext secret", () => { - expect(secretHash("abcdefghijklmnopqrst", "my-secret")).toBe( + expect(secretHash("abcdefghijklmnopqrst", "my-secret", [])).toBe( "hash:64800db722cc0be9e1d816d5aed626805e91a939d2dbcbc5239cd31eeef763e9", ); - expect(secretHash("test", "topsecret")).toBe( + expect(secretHash("test", "topsecret", [])).toBe( "hash:8eed2826599c798e072951884ced30954f8322fa1c3648506634e8376a740d72", ); }); it("keys the HMAC on the project ref (same value, different ref → different hash)", () => { - expect(secretHash("ref-a", "same")).not.toBe(secretHash("ref-b", "same")); + expect(secretHash("ref-a", "same", [])).not.toBe(secretHash("ref-b", "same", [])); }); it("returns '' for an empty value", () => { - expect(secretHash("abcdefghijklmnopqrst", "")).toBe(""); + expect(secretHash("abcdefghijklmnopqrst", "", [])).toBe(""); }); it("returns '' for an unresolved env() reference", () => { - expect(secretHash("abcdefghijklmnopqrst", "env(MY_SECRET)")).toBe(""); - expect(secretHash("abcdefghijklmnopqrst", "env()")).toBe(""); - }); - - it("returns '' for a dotenvx encrypted: value (never hashes/pushes ciphertext)", () => { - // Regression: hashing/pushing the ciphertext would overwrite the remote - // secret with garbage. Treated as unresolved, like env(). - expect(secretHash("abcdefghijklmnopqrst", "encrypted:BvEYU1pXk9...")).toBe(""); + expect(secretHash("abcdefghijklmnopqrst", "env(MY_SECRET)", [])).toBe(""); + expect(secretHash("abcdefghijklmnopqrst", "env()", [])).toBe(""); }); it("hashes a value that merely contains (but does not start with) 'encrypted:'", () => { // Only the dotenvx prefix is special; an embedded substring is a real secret. - expect(secretHash("test", "not-encrypted:value")).toBe( - secretHash("test", "not-encrypted:value"), + expect(secretHash("test", "not-encrypted:value", [])).toBe( + secretHash("test", "not-encrypted:value", []), + ); + expect(secretHash("test", "not-encrypted:value", []).startsWith("hash:")).toBe(true); + }); + + describe("dotenvx encrypted: values", () => { + it("decrypts before hashing (hash matches the decrypted plaintext, not the ciphertext)", () => { + expect(secretHash("abcdefghijklmnopqrst", ENCRYPTED_VALUE, [PRIVATE_KEY])).toBe( + secretHash("abcdefghijklmnopqrst", "value", []), + ); + }); + + it("tries each key and the first working one wins", () => { + expect(secretHash("test", ENCRYPTED_VALUE, [WRONG_KEY, PRIVATE_KEY])).toBe( + secretHash("test", "value", []), + ); + }); + + it("throws 'failed to parse config: missing private key' with no keys", () => { + expect(() => secretHash("test", ENCRYPTED_VALUE, [])).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("throws 'failed to parse config: failed to decrypt secret: ...' for a wrong key", () => { + expect(() => secretHash("test", ENCRYPTED_VALUE, [WRONG_KEY])).toThrow( + /^failed to parse config: failed to decrypt secret:/, + ); + }); + }); +}); + +describe("secretPlaintext", () => { + it("returns a plain value unchanged", () => { + expect(secretPlaintext("my-secret", [])).toBe("my-secret"); + expect(secretPlaintext("", [])).toBe(""); + }); + + it("decrypts a dotenvx encrypted: value to its plaintext", () => { + expect(secretPlaintext(ENCRYPTED_VALUE, [PRIVATE_KEY])).toBe("value"); + }); + + it("never returns the ciphertext when decryption fails — throws instead", () => { + expect(() => secretPlaintext(ENCRYPTED_VALUE, [])).toThrow( + "failed to parse config: missing private key", ); - expect(secretHash("test", "not-encrypted:value").startsWith("hash:")).toBe(true); }); }); diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index ebd07baabc..e1fc8971f3 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -8,9 +8,13 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyAssertDecryptableSecrets, + legacyLoadProjectEnv, +} from "../../../shared/legacy-db-config.toml-read.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyCollectDotenvPrivateKeys } from "../../../shared/legacy-vault-decrypt.ts"; import { apiSubsetFromConfig, apiToUpdateBody, diffApiWithRemote } from "./config-sync/api.sync.ts"; import { applyRemoteAuthConfig, @@ -101,6 +105,19 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const projectRoot = (yield* findProjectRoot(runtimeInfo.cwd)) ?? runtimeInfo.cwd; const projectEnv = yield* legacyLoadProjectEnv(fs, path, projectRoot); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + // dotenvx private keys for decrypting `encrypted:` secrets (Go's + // `DecryptSecretHookFunc`), from the shell + project env — same source/precedence + // as `legacy-db-config.toml-read.ts` (`process.env` wins over `supabase/.env`). + const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnv, ...process.env }); + // Only reached by `legacyAssertDecryptableSecrets` below for an `env(VAR)` literal that + // survives `loaded.document`'s own (`@supabase/config`) interpolation pass unresolved — i.e. + // when this wider env source (matching Go's `loadNestedEnv`) resolves `VAR` but + // `@supabase/config`'s narrower one (`supabase/.env`/`.env.local` only) didn't. Practically + // unreachable in the same narrow way the CLI-1489 comment below already documents for + // non-secret fields; kept for parity with the shared function's other caller + // (`legacy-db-config.toml-read.ts`, whose pre-interpolation document relies on this). + const secretEnvLookup = (name: string): string | undefined => + process.env[name] ?? projectEnv[name]; const ref = yield* resolver.resolve(flags.projectRef); @@ -116,7 +133,10 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // Pass `ref` so a matching `[remotes.*]` block is merged over the base config // before decode (Go's `loadFromFile` with `Config.ProjectId` set). A duplicate // `project_id` across remotes surfaces Go's verbatim message. - const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { projectRef: ref }).pipe( + const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }).pipe( Effect.catchTag( "ProjectConfigParseError", (cause) => @@ -141,6 +161,33 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const projectId = ref; const config = loaded.config; + // 1b. Assert every `config.Secret`-typed `encrypted:` value in the document + // (not just auth.*) can be decrypted, mirroring Go's global + // `DecryptSecretHookFunc`, which runs as part of `config.Load` — before + // `internal/config/push.Run` ever reads the cost matrix (`push.go:16` vs. + // `push.go:25`) or touches any service. An undecryptable secret anywhere in + // the document (even one `config push` never itself pushes, e.g. + // `studio.openai_api_key`) aborts here with Go's own `failed to parse + // config: ` message, before any remote service is read or updated. + // + // `loaded.document` has already had Go's deprecated `auth.external.{linkedin,slack}` + // blocks stripped by `@supabase/config` (`normalizeDeprecatedExternalProviders`), but + // Go's decrypt hook runs at decode time — before its own later `external.validate()` + // deletes those blocks — so an `encrypted:` secret hiding in one of them still aborts + // Go's load. Fold `removedDeprecatedExternalProviders` back into a synthetic + // `auth.external` view and scan that too, reusing the same path list rather than a + // second scanner. + const secretError = + legacyAssertDecryptableSecrets(loaded.document, secretEnvLookup, dotenvPrivateKeys) ?? + legacyAssertDecryptableSecrets( + { auth: { external: loaded.removedDeprecatedExternalProviders ?? {} } }, + secretEnvLookup, + dotenvPrivateKeys, + ); + if (secretError !== undefined) { + return yield* new LegacyConfigPushLoadConfigError({ message: secretError }); + } + // Optional `*pointer` sections (ssl_enforcement, image_transformation, // s3_protocol) are defaulted-present by @supabase/config and cannot be // recovered from the decoded config, so we inspect the raw (merged) document @@ -360,7 +407,19 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( }), ), ); - let local = authSubsetFromConfig(config, projectId, presence.auth, authEmailContent); + // `dotenvPrivateKeys` decrypts any `encrypted:` auth secret before it's + // hashed/copied into the update body. The document-wide check above + // (step 1b) already scanned every `config.Secret` path — including every + // field `authSubsetFromConfig` reads — and would have aborted by now if + // any were undecryptable, so the decrypt calls inside it are unreachable + // failure paths here, not a real branch to guard with `Effect.try`. + let local = authSubsetFromConfig( + config, + projectId, + presence.auth, + authEmailContent, + dotenvPrivateKeys, + ); const projected = applyRemoteAuthConfig(local, remote); // MFA phone/webauthn are paid addons: confirm cost before enabling. if (mfaPhoneNewlyEnabled(local, projected) && !(yield* keep("auth_mfa_phone"))) { diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 02ccd9d77c..0713bf7be0 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -29,6 +29,31 @@ function writeConfig(toml: string): void { writeFileSync(join(dir, "config.toml"), toml); } +// Go's test vector — `apps/cli-go/pkg/config/secret_test.go:9-19` (same one +// `legacy-vault-decrypt.unit.test.ts` and `config-sync.secret.unit.test.ts` use). +// Decrypts to the plaintext "value". +const DOTENVX_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; +const DOTENVX_ENCRYPTED_VALUE = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + +/** Save/restore `DOTENV_PRIVATE_KEY` around a test — mirrors the SUPABASE_YES pattern below. */ +function withDotenvPrivateKey( + value: string | undefined, + effect: Effect.Effect, +): Effect.Effect { + const prev = process.env["DOTENV_PRIVATE_KEY"]; + if (value === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; + else process.env["DOTENV_PRIVATE_KEY"] = value; + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; + else process.env["DOTENV_PRIVATE_KEY"] = prev; + }), + ), + ); +} + // Schema-valid PostgREST GET response with the api disabled remotely (empty // schema). The real API client validates GET bodies against the generated // output schema, so every postgrest GET must carry these fields. @@ -724,6 +749,172 @@ secret = "my-plaintext-secret" }, ); + it.live( + "decrypts a dotenvx encrypted: captcha secret and pushes the plaintext (CLI-1881)", + () => { + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = true +site_url = "http://localhost:3000" +[auth.captcha] +enabled = true +provider = "hcaptcha" +secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, apiMock } = setupService({ + toml, + yes: true, + v1: { + getAuthServiceConfig: () => Effect.succeed({}), + updateAuthServiceConfig: () => Effect.succeed({}), + }, + }); + return withDotenvPrivateKey( + DOTENVX_PRIVATE_KEY, + Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); + expect(update).toBeDefined(); + const input = update?.input as Record; + // Go decrypts before hashing/pushing — the plaintext goes to the API, + // never the dotenvx ciphertext. + expect(input["security_captcha_secret"]).toBe("value"); + }).pipe(Effect.provide(layer)), + ); + }, + ); + + it.live( + "aborts before any network call when an encrypted: secret cannot be decrypted (CLI-1881)", + () => { + // `auth.enabled = false` on purpose: Go's decrypt hook runs for every + // `config.Secret` field during `config.Load`, before any feature gate is + // consulted — an undecryptable secret aborts even when the section that + // contains it would otherwise be skipped entirely. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[auth.captcha] +enabled = true +provider = "hcaptcha" +secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + // The guard runs during config load, before any network call — not + // even the cost-matrix (list-addons) request that normally runs first. + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }, + ); + + it.live( + "aborts on an undecryptable secret config push never itself reads or pushes (CLI-1881)", + () => { + // `studio.openai_api_key` is a `config.Secret` field Go's `DecryptSecretHookFunc` + // still decrypts during `config.Load` — but no `config-sync/*.sync.ts` file (api, + // db, auth, storage, experimental) ever reads `studio.*`, so this proves the + // pre-check is genuinely document-wide, not merely reachable via `auth.*`. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[studio] +openai_api_key = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }, + ); + + it.live("aborts on an undecryptable [db.vault] secret (CLI-1881)", () => { + // `db.vault` decodes as `map[string]Secret` in Go (`pkg/config/db.go:96`), so Go's + // decrypt hook covers it during `config.Load` — but the shared + // `legacyAssertDecryptableSecrets` path list used to omit `db.vault` on the theory + // that only the db-config reader's own downstream vault loop needed it. `config push` + // never runs that downstream loop, so this proves the shared path list now covers + // `db.vault` for every caller. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[db.vault] +my_secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }); + + it.live( + "aborts on an undecryptable secret in a deprecated [auth.external.slack] block (CLI-1881)", + () => { + // `@supabase/config` strips `auth.external.{linkedin,slack}` from `loaded.document` + // before returning it (`normalizeDeprecatedExternalProviders`), matching Go's + // `external.validate()` — but Go's decrypt hook runs at DECODE time, strictly before + // that later validate-time deletion, so a `secret` hiding in one of these deprecated + // blocks still aborts Go's load. This proves the pre-check folds + // `removedDeprecatedExternalProviders` back in rather than missing it. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[auth.external.slack] +secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }, + ); + it.live("pushes storage when enabled and changed", () => { const toml = `project_id = "test" [auth] diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts index 628b98db8b..2a9e55fc24 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts @@ -46,10 +46,10 @@ export const legacyDbAdvisorsCommand = Command.make("advisors", config).pipe( level: flags.level, "fail-on": flags.failOn, }, - // Go's changedFlagValues records every utils.EnumFlag verbatim - // (cmd/root_analytics.go:88-116). --db-url stays redacted (plain string, - // may carry secrets); --linked/--local are booleans (passed through). - safeFlags: ["type", "level", "fail-on"], + // type/level/fail-on are Flag.choice and are auto-detected as safe via + // `config` below (Go's isEnumFlag, cmd/root_analytics.go:110-116); + // --db-url stays redacted (plain string, may carry secrets). + config, }), withJsonErrorHandling, ), diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.format.unit.test.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.format.unit.test.ts index ef64d7fdb4..e293152d44 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.format.unit.test.ts @@ -385,4 +385,12 @@ describe("splitLegacyLintsSql", () => { expect(setup).toBe("set local search_path = ''"); expect(query.startsWith("(")).toBe(true); }); + + it("defaults API-exposure lints to public when pgrst.db_schemas is unset", () => { + const [, query] = splitLegacyLintsSql(); + expect(query).not.toContain("string_to_array(current_setting('pgrst.db_schemas', 't'), ',')"); + expect(query).toContain( + "string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')", + ); + }); }); diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.lints-sql.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.lints-sql.ts index 97d8947c15..5cf4bfb06a 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.lints-sql.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.lints-sql.ts @@ -10,7 +10,7 @@ * (`advisors.go:154-160`). */ const LEGACY_ADVISORS_LINTS_SQL = - "set local search_path = '';\n\n(\nwith foreign_keys as (\n select\n cl.relnamespace::regnamespace::text as schema_name,\n cl.relname as table_name,\n cl.oid as table_oid,\n ct.conname as fkey_name,\n ct.conkey as col_attnums\n from\n pg_catalog.pg_constraint ct\n join pg_catalog.pg_class cl -- fkey owning table\n on ct.conrelid = cl.oid\n left join pg_catalog.pg_depend d\n on d.objid = cl.oid\n and d.deptype = 'e'\n where\n ct.contype = 'f' -- foreign key constraints\n and d.objid is null -- exclude tables that are dependencies of extensions\n and cl.relnamespace::regnamespace::text not in (\n 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions'\n )\n),\nindex_ as (\n select\n pi.indrelid as table_oid,\n indexrelid::regclass as index_,\n string_to_array(indkey::text, ' ')::smallint[] as col_attnums\n from\n pg_catalog.pg_index pi\n where\n indisvalid\n)\nselect\n 'unindexed_foreign_keys' as name,\n 'Unindexed foreign keys' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Identifies foreign key constraints without a covering index, which can impact database performance.' as description,\n format(\n 'Table `%s.%s` has a foreign key `%s` without a covering index. This can lead to suboptimal query performance.',\n fk.schema_name,\n fk.table_name,\n fk.fkey_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as remediation,\n jsonb_build_object(\n 'schema', fk.schema_name,\n 'name', fk.table_name,\n 'type', 'table',\n 'fkey_name', fk.fkey_name,\n 'fkey_columns', fk.col_attnums\n ) as metadata,\n format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as cache_key\nfrom\n foreign_keys fk\n left join index_ idx\n on fk.table_oid = idx.table_oid\n and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)]\n left join pg_catalog.pg_depend dep\n on idx.table_oid = dep.objid\n and dep.deptype = 'e'\nwhere\n idx.index_ is null\n and fk.schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude tables owned by extensions\norder by\n fk.schema_name,\n fk.table_name,\n fk.fkey_name)\nunion all\n(\nselect\n 'auth_users_exposed' as name,\n 'Exposed Auth Users' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as description,\n format(\n 'View/Materialized View \"%s\" in the public schema may expose `auth.users` data to anon or authenticated roles.',\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'view',\n 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)\n ) as metadata,\n format('auth_users_exposed_%s_%s', n.nspname, c.relname) as cache_key\nfrom\n -- Identify the oid for auth.users\n pg_catalog.pg_class auth_users_pg_class\n join pg_catalog.pg_namespace auth_users_pg_namespace\n on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid\n and auth_users_pg_class.relname = 'users'\n and auth_users_pg_namespace.nspname = 'auth'\n -- Depends on auth.users\n join pg_catalog.pg_depend d\n on d.refobjid = auth_users_pg_class.oid\n join pg_catalog.pg_rewrite r\n on r.oid = d.objid\n join pg_catalog.pg_class c\n on c.oid = r.ev_class\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n join pg_catalog.pg_class pg_class_auth_users\n on d.refobjid = pg_class_auth_users.oid\nwhere\n d.deptype = 'n'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n -- Exclude self\n and c.relname <> '0002_auth_users_exposed'\n -- There are 3 insecure configurations\n and\n (\n -- Materialized views don't support RLS so this is insecure by default\n (c.relkind in ('m')) -- m for materialized view\n or\n -- Standard View, accessible to anon or authenticated that is security_definer\n (\n c.relkind = 'v' -- v for view\n -- Exclude security invoker views\n and not (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n )\n )\n or\n -- Standard View, security invoker, but no RLS enabled on auth.users\n (\n c.relkind in ('v') -- v for view\n -- is security invoker\n and (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n )\n and not pg_class_auth_users.relrowsecurity\n )\n )\ngroup by\n n.nspname,\n c.relname,\n c.oid)\nunion all\n(\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n pc.relrowsecurity as is_rls_active,\n polname as policy_name,\n polpermissive as is_permissive, -- if not, then restrictive\n (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles,\n case polcmd\n when 'r' then 'SELECT'\n when 'a' then 'INSERT'\n when 'w' then 'UPDATE'\n when 'd' then 'DELETE'\n when '*' then 'ALL'\n end as command,\n qual,\n with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n)\nselect\n 'auth_rls_initplan' as name,\n 'Auth RLS Initialization Plan' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if calls to `current_setting()` and `auth.()` in RLS policies are being unnecessarily re-evaluated for each row' as description,\n format(\n 'Table `%s.%s` has a row level security policy `%s` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.()` with `(select auth.())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.',\n schema_name,\n table_name,\n policy_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table'\n ) as metadata,\n format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as cache_key\nfrom\n policies\nwhere\n is_rls_active\n -- NOTE: does not include realtime in support of monitoring policies on realtime.messages\n and schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and (\n -- Example: auth.uid()\n (\n qual like '%auth.uid()%'\n and lower(qual) not like '%select auth.uid()%'\n )\n or (\n qual like '%auth.jwt()%'\n and lower(qual) not like '%select auth.jwt()%'\n )\n or (\n qual like '%auth.role()%'\n and lower(qual) not like '%select auth.role()%'\n )\n or (\n qual like '%auth.email()%'\n and lower(qual) not like '%select auth.email()%'\n )\n or (\n qual like '%current\\_setting(%)%'\n and lower(qual) not like '%select current\\_setting(%)%'\n )\n or (\n with_check like '%auth.uid()%'\n and lower(with_check) not like '%select auth.uid()%'\n )\n or (\n with_check like '%auth.jwt()%'\n and lower(with_check) not like '%select auth.jwt()%'\n )\n or (\n with_check like '%auth.role()%'\n and lower(with_check) not like '%select auth.role()%'\n )\n or (\n with_check like '%auth.email()%'\n and lower(with_check) not like '%select auth.email()%'\n )\n or (\n with_check like '%current\\_setting(%)%'\n and lower(with_check) not like '%select current\\_setting(%)%'\n )\n ))\nunion all\n(\nselect\n 'no_primary_key' as name,\n 'No Primary Key' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as description,\n format(\n 'Table `%s.%s` does not have a primary key',\n pgns.nspname,\n pgc.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as remediation,\n jsonb_build_object(\n 'schema', pgns.nspname,\n 'name', pgc.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'no_primary_key_%s_%s',\n pgns.nspname,\n pgc.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class pgc\n join pg_catalog.pg_namespace pgns\n on pgns.oid = pgc.relnamespace\n left join pg_catalog.pg_index pgi\n on pgi.indrelid = pgc.oid\n left join pg_catalog.pg_depend dep\n on pgc.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n pgc.relkind = 'r' -- regular tables\n and pgns.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n pgc.oid,\n pgns.nspname,\n pgc.relname\nhaving\n max(coalesce(pgi.indisprimary, false)::int) = 0)\nunion all\n(\nselect\n 'unused_index' as name,\n 'Unused Index' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if an index has never been used and may be a candidate for removal.' as description,\n format(\n 'Index `%s` on table `%s.%s` has not been used',\n psui.indexrelname,\n psui.schemaname,\n psui.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as remediation,\n jsonb_build_object(\n 'schema', psui.schemaname,\n 'name', psui.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'unused_index_%s_%s_%s',\n psui.schemaname,\n psui.relname,\n psui.indexrelname\n ) as cache_key\n\nfrom\n pg_catalog.pg_stat_user_indexes psui\n join pg_catalog.pg_index pi\n on psui.indexrelid = pi.indexrelid\n left join pg_catalog.pg_depend dep\n on psui.relid = dep.objid\n and dep.deptype = 'e'\nwhere\n psui.idx_scan = 0\n and not pi.indisunique\n and not pi.indisprimary\n and dep.objid is null -- exclude tables owned by extensions\n and psui.schemaname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n ))\nunion all\n(\nselect\n 'multiple_permissive_policies' as name,\n 'Multiple Permissive Policies' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as description,\n format(\n 'Table `%s.%s` has multiple permissive policies for role `%s` for action `%s`. Policies include `%s`',\n n.nspname,\n c.relname,\n r.rolname,\n act.cmd,\n array_agg(p.polname order by p.polname)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'multiple_permissive_policies_%s_%s_%s_%s',\n n.nspname,\n c.relname,\n r.rolname,\n act.cmd\n ) as cache_key\nfrom\n pg_catalog.pg_policy p\n join pg_catalog.pg_class c\n on p.polrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n join pg_catalog.pg_roles r\n on p.polroles @> array[r.oid]\n or p.polroles = array[0::oid]\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e',\n lateral (\n select x.cmd\n from unnest((\n select\n case p.polcmd\n when 'r' then array['SELECT']\n when 'a' then array['INSERT']\n when 'w' then array['UPDATE']\n when 'd' then array['DELETE']\n when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE']\n else array['ERROR']\n end as actions\n )) x(cmd)\n ) act(cmd)\nwhere\n c.relkind = 'r' -- regular tables\n and p.polpermissive -- policy is permissive\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and r.rolname not like 'pg_%'\n and r.rolname not like 'supabase%admin'\n and not r.rolbypassrls\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relname,\n r.rolname,\n act.cmd\nhaving\n count(1) > 1)\nunion all\n(\nselect\n 'policy_exists_rls_disabled' as name,\n 'Policy Exists RLS Disabled' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as description,\n format(\n 'Table `%s.%s` has RLS policies but RLS is not enabled on the table. Policies include %s.',\n n.nspname,\n c.relname,\n array_agg(p.polname order by p.polname)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'policy_exists_rls_disabled_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_policy p\n join pg_catalog.pg_class c\n on p.polrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'r' -- regular tables\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n -- RLS is disabled\n and not c.relrowsecurity\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relname)\nunion all\n(\nselect\n 'rls_enabled_no_policy' as name,\n 'RLS Enabled No Policy' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as description,\n format(\n 'Table `%s.%s` has RLS enabled, but no policies exist',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_enabled_no_policy_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n left join pg_catalog.pg_policy p\n on p.polrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'r' -- regular tables\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n -- RLS is enabled\n and c.relrowsecurity\n and p.polname is null\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relname)\nunion all\n(\nselect\n 'duplicate_index' as name,\n 'Duplicate Index' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects cases where two ore more identical indexes exist.' as description,\n format(\n 'Table `%s.%s` has identical indexes %s. Drop all except one of them',\n n.nspname,\n c.relname,\n array_agg(pi.indexname order by pi.indexname)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', case\n when c.relkind = 'r' then 'table'\n when c.relkind = 'm' then 'materialized view'\n else 'ERROR'\n end,\n 'indexes', array_agg(pi.indexname order by pi.indexname)\n ) as metadata,\n format(\n 'duplicate_index_%s_%s_%s',\n n.nspname,\n c.relname,\n array_agg(pi.indexname order by pi.indexname)\n ) as cache_key\nfrom\n pg_catalog.pg_indexes pi\n join pg_catalog.pg_namespace n\n on n.nspname = pi.schemaname\n join pg_catalog.pg_class c\n on pi.tablename = c.relname\n and n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind in ('r', 'm') -- tables and materialized views\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relkind,\n c.relname,\n replace(pi.indexdef, pi.indexname, '')\nhaving\n count(*) > 1)\nunion all\n(\nselect\n 'security_definer_view' as name,\n 'Security Definer View' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as description,\n format(\n 'View `%s.%s` is defined with the SECURITY DEFINER property',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'view'\n ) as metadata,\n format(\n 'security_definer_view_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'v'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude views owned by extensions\n and not (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n ))\nunion all\n(\nselect\n 'function_search_path_mutable' as name,\n 'Function Search Path Mutable' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects functions where the search_path parameter is not set.' as description,\n format(\n 'Function `%s.%s` has a role mutable search_path',\n n.nspname,\n p.proname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', p.proname,\n 'type', 'function'\n ) as metadata,\n format(\n 'function_search_path_mutable_%s_%s_%s',\n n.nspname,\n p.proname,\n md5(p.prosrc) -- required when function is polymorphic\n ) as cache_key\nfrom\n pg_catalog.pg_proc p\n join pg_catalog.pg_namespace n\n on p.pronamespace = n.oid\n left join pg_catalog.pg_depend dep\n on p.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude functions owned by extensions\n -- Search path not set\n and not exists (\n select 1\n from unnest(coalesce(p.proconfig, '{}')) as config\n where config like 'search_path=%'\n ))\nunion all\n(\nselect\n 'rls_disabled_in_public' as name,\n 'RLS Disabled in Public' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as description,\n format(\n 'Table `%s.%s` is public, but RLS has not been enabled.',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_disabled_in_public_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\nwhere\n c.relkind = 'r' -- regular tables\n -- RLS is disabled\n and not c.relrowsecurity\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n ))\nunion all\n(\nselect\n 'extension_in_public' as name,\n 'Extension in Public' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects extensions installed in the `public` schema.' as description,\n format(\n 'Extension `%s` is installed in the public schema. Move it to another schema.',\n pe.extname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as remediation,\n jsonb_build_object(\n 'schema', pe.extnamespace::regnamespace,\n 'name', pe.extname,\n 'type', 'extension'\n ) as metadata,\n format(\n 'extension_in_public_%s',\n pe.extname\n ) as cache_key\nfrom\n pg_catalog.pg_extension pe\nwhere\n -- plpgsql is installed by default in public and outside user control\n -- confirmed safe\n pe.extname not in ('plpgsql')\n -- Scoping this to public is not optimal. Ideally we would use the postgres\n -- search path. That currently isn't available via SQL. In other lints\n -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that\n -- is not appropriate here as it would evaluate true for the extensions schema\n and pe.extnamespace::regnamespace::text = 'public')\nunion all\n(\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n polname as policy_name,\n qual,\n with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n)\nselect\n 'rls_references_user_metadata' as name,\n 'RLS references user metadata' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as description,\n format(\n 'Table `%s.%s` has a row level security policy `%s` that references Supabase Auth `user_metadata`. `user_metadata` is editable by end users and should never be used in a security context.',\n schema_name,\n table_name,\n policy_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table'\n ) as metadata,\n format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as cache_key\nfrom\n policies\nwhere\n schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and (\n -- Example: auth.jwt() -> 'user_metadata'\n -- False positives are possible, but it isn't practical to string match\n -- If false positive rate is too high, this expression can iterate\n qual like '%auth.jwt()%user_metadata%'\n or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'\n or with_check like '%auth.jwt()%user_metadata%'\n or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'\n ))\nunion all\n(\nselect\n 'materialized_view_in_api' as name,\n 'Materialized View in API' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects materialized views that are accessible over the Data APIs.' as description,\n format(\n 'Materialized view `%s.%s` is selectable by anon or authenticated roles',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'materialized view'\n ) as metadata,\n format(\n 'materialized_view_in_api_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'm'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null)\nunion all\n(\nselect\n 'foreign_table_in_api' as name,\n 'Foreign Table in API' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as description,\n format(\n 'Foreign table `%s.%s` is accessible over APIs',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'foreign table'\n ) as metadata,\n format(\n 'foreign_table_in_api_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'f'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null)\nunion all\n(\nselect\n 'unsupported_reg_types' as name,\n 'Unsupported reg types' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as description,\n format(\n 'Table `%s.%s` has a column `%s` with unsupported reg* type `%s`.',\n n.nspname,\n c.relname,\n a.attname,\n t.typname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'column', a.attname,\n 'type', 'table'\n ) as metadata,\n format(\n 'unsupported_reg_types_%s_%s_%s',\n n.nspname,\n c.relname,\n a.attname\n ) AS cache_key\nfrom\n pg_catalog.pg_attribute a\n join pg_catalog.pg_class c\n on a.attrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n join pg_catalog.pg_type t\n on a.atttypid = t.oid\n join pg_catalog.pg_namespace tn\n on t.typnamespace = tn.oid\nwhere\n tn.nspname = 'pg_catalog'\n and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure')\n and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium'))\nunion all\n(\nselect\n 'insecure_queue_exposed_in_api' as name,\n 'Insecure Queue Exposed in API' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where an insecure Queue is exposed over Data APIs' as description,\n format(\n 'Table `%s.%s` is public, but RLS has not been enabled.',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_disabled_in_public_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\nwhere\n c.relkind in ('r', 'I') -- regular or partitioned tables\n and not c.relrowsecurity -- RLS is disabled\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = 'pgmq' -- tables in the pgmq schema\n and c.relname like 'q_%' -- only queue tables\n -- Constant requirements\n and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))))\nunion all\n(\nwith constants as (\n select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma\n),\n\nbloat_info as (\n select\n ma,\n bs,\n schemaname,\n tablename,\n (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr,\n (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2\n from (\n select\n schemaname,\n tablename,\n hdr,\n ma,\n bs,\n sum((1 - null_frac) * avg_width) as datawidth,\n max(null_frac) as maxfracsum,\n hdr + (\n select 1 + count(*) / 8\n from pg_stats s2\n where\n null_frac <> 0\n and s2.schemaname = s.schemaname\n and s2.tablename = s.tablename\n ) as nullhdr\n from pg_stats s, constants\n group by 1, 2, 3, 4, 5\n ) as foo\n),\n\ntable_bloat as (\n select\n schemaname,\n tablename,\n cc.relpages,\n bs,\n ceil((cc.reltuples * ((datahdr + ma -\n (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta\n from\n bloat_info\n join pg_class cc\n on cc.relname = bloat_info.tablename\n join pg_namespace nn\n on cc.relnamespace = nn.oid\n and nn.nspname = bloat_info.schemaname\n and nn.nspname <> 'information_schema'\n where\n cc.relkind = 'r'\n and cc.relam = (select oid from pg_am where amname = 'heap')\n),\n\nbloat_data as (\n select\n 'table' as type,\n schemaname,\n tablename as object_name,\n round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat,\n case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste\n from\n table_bloat\n)\n\nselect\n 'table_bloat' as name,\n 'Table Bloat' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as description,\n format(\n 'Table `%s`.`%s` has excessive bloat',\n bloat_data.schemaname,\n bloat_data.object_name\n ) as detail,\n 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as remediation,\n jsonb_build_object(\n 'schema', bloat_data.schemaname,\n 'name', bloat_data.object_name,\n 'type', bloat_data.type\n ) as metadata,\n format(\n 'table_bloat_%s_%s',\n bloat_data.schemaname,\n bloat_data.object_name\n ) as cache_key\nfrom\n bloat_data\nwhere\n bloat > 70.0\n and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB\norder by\n schemaname,\n object_name)\nunion all\n(\nselect\n 'fkey_to_auth_unique' as name,\n 'Foreign Key to Auth Unique Constraint' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects user defined foreign keys to unique constraints in the auth schema.' as description,\n format(\n 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint',\n n.nspname, -- referencing schema\n c_rel.relname, -- referencing table\n c.conname -- fkey name\n ) as detail,\n 'Drop the foreign key constraint that references the auth schema.' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c_rel.relname,\n 'foreign_key', c.conname\n ) as metadata,\n format(\n 'fkey_to_auth_unique_%s_%s_%s',\n n.nspname, -- referencing schema\n c_rel.relname, -- referencing table\n c.conname\n ) as cache_key\nfrom\n pg_catalog.pg_constraint c\n join pg_catalog.pg_class c_rel\n on c.conrelid = c_rel.oid\n join pg_catalog.pg_namespace n\n on c_rel.relnamespace = n.oid\n join pg_catalog.pg_class ref_rel\n on c.confrelid = ref_rel.oid\n join pg_catalog.pg_namespace cn\n on ref_rel.relnamespace = cn.oid\n join pg_catalog.pg_index i\n on c.conindid = i.indexrelid\nwhere c.contype = 'f'\n and cn.nspname = 'auth'\n and i.indisunique\n and not i.indisprimary)\nunion all\n(\nselect\n 'extension_versions_outdated' as name,\n 'Extension Versions Outdated' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects extensions that are not using the default (recommended) version.' as description,\n format(\n 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.',\n ext.name,\n ext.installed_version,\n ext.default_version\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as remediation,\n jsonb_build_object(\n 'extension_name', ext.name,\n 'installed_version', ext.installed_version,\n 'default_version', ext.default_version\n ) as metadata,\n format(\n 'extension_versions_outdated_%s_%s',\n ext.name,\n ext.installed_version\n ) as cache_key\nfrom\n pg_catalog.pg_available_extensions ext\njoin\n -- ignore versions not in pg_available_extension_versions\n -- e.g. residue of pg_upgrade\n pg_catalog.pg_available_extension_versions extv\n on extv.name = ext.name and extv.installed\nwhere\n ext.installed_version is not null\n and ext.default_version is not null\n and ext.installed_version != ext.default_version\norder by\n ext.name)\nunion all\n(\n-- Detects tables exposed via API that contain columns with sensitive names\n-- Inspired by patterns from security scanners that detect PII/credential exposure\nwith sensitive_patterns as (\n select unnest(array[\n -- Authentication & Credentials\n 'password', 'passwd', 'pwd', 'passphrase',\n 'secret', 'secret_key', 'private_key', 'api_key', 'apikey',\n 'auth_key', 'token', 'jwt', 'access_token', 'refresh_token',\n 'oauth_token', 'session_token', 'bearer_token', 'auth_code',\n 'session_id', 'session_key', 'session_secret',\n 'recovery_code', 'backup_code', 'verification_code',\n 'otp', 'two_factor', '2fa_secret', '2fa_code',\n -- Personal Identifiers\n 'ssn', 'social_security', 'social_security_number',\n 'driver_license', 'drivers_license', 'license_number',\n 'passport_number', 'passport_id', 'national_id', 'tax_id',\n -- Financial Information\n 'credit_card', 'card_number', 'cvv', 'cvc', 'cvn',\n 'bank_account', 'account_number', 'routing_number',\n 'iban', 'swift_code', 'bic',\n -- Health & Medical\n 'health_record', 'medical_record', 'patient_id',\n 'insurance_number', 'health_insurance', 'medical_insurance',\n 'treatment',\n -- Device Identifiers\n 'mac_address', 'macaddr', 'imei', 'device_uuid',\n -- Digital Keys & Certificates\n 'pgp_key', 'gpg_key', 'ssh_key', 'certificate',\n 'license_key', 'activation_key',\n -- Biometric Data\n 'facial_recognition'\n ]) as pattern\n),\nexposed_tables as (\n select\n n.nspname as schema_name,\n c.relname as table_name,\n c.oid as table_oid\n from\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n where\n c.relkind = 'r' -- regular tables\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n -- Only flag tables without RLS enabled\n and not c.relrowsecurity\n),\nsensitive_columns as (\n select\n et.schema_name,\n et.table_name,\n a.attname as column_name,\n sp.pattern as matched_pattern\n from\n exposed_tables et\n join pg_catalog.pg_attribute a\n on a.attrelid = et.table_oid\n and a.attnum > 0\n and not a.attisdropped\n cross join sensitive_patterns sp\n where\n -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants\n replace(lower(a.attname), '-', '_') = sp.pattern\n)\nselect\n 'sensitive_columns_exposed' as name,\n 'Sensitive Columns Exposed' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.' as description,\n format(\n 'Table `%s.%s` is exposed via API without RLS and contains potentially sensitive column(s): %s. This may lead to data exposure.',\n schema_name,\n table_name,\n string_agg(distinct column_name, ', ' order by column_name)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table',\n 'sensitive_columns', array_agg(distinct column_name order by column_name),\n 'matched_patterns', array_agg(distinct matched_pattern order by matched_pattern)\n ) as metadata,\n format(\n 'sensitive_columns_exposed_%s_%s',\n schema_name,\n table_name\n ) as cache_key\nfrom\n sensitive_columns\ngroup by\n schema_name,\n table_name\norder by\n schema_name,\n table_name)\nunion all\n(\n-- Detects RLS policies that are overly permissive (e.g., USING (true), USING (1=1))\n-- These policies effectively disable row-level security while giving a false sense of security\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n pc.relrowsecurity as is_rls_active,\n pa.polname as policy_name,\n pa.polpermissive as is_permissive,\n pa.polroles as role_oids,\n (select array_agg(r::regrole::text) from unnest(pa.polroles) as x(r)) as roles,\n case pa.polcmd\n when 'r' then 'SELECT'\n when 'a' then 'INSERT'\n when 'w' then 'UPDATE'\n when 'd' then 'DELETE'\n when '*' then 'ALL'\n end as command,\n pb.qual,\n pb.with_check,\n -- Normalize expressions by removing whitespace and lowercasing\n replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\\n', ''), E'\\t', '') as normalized_qual,\n replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\\n', ''), E'\\t', '') as normalized_with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n where\n pc.relkind = 'r' -- regular tables\n and nsp.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n),\npermissive_patterns as (\n select\n p.*,\n -- Check for always-true USING clause patterns\n -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE\n case when (\n command in ('UPDATE', 'DELETE', 'ALL')\n and (\n normalized_qual in ('true', '(true)', '1=1', '(1=1)')\n -- Empty or null qual on permissive policy means allow all\n or (qual is null and is_permissive)\n )\n ) then true else false end as has_permissive_using,\n -- Check for always-true WITH CHECK clause patterns\n case when (\n normalized_with_check in ('true', '(true)', '1=1', '(1=1)')\n -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on)\n or (with_check is null and is_permissive and command = 'INSERT')\n -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes\n or (with_check is null and is_permissive and command in ('UPDATE', 'ALL')\n and normalized_qual in ('true', '(true)', '1=1', '(1=1)'))\n ) then true else false end as has_permissive_with_check\n from\n policies p\n where\n -- Only check tables with RLS enabled (otherwise it's a different lint)\n is_rls_active\n -- Only check permissive policies (restrictive policies with true are less dangerous)\n and is_permissive\n -- Only flag policies that apply to anon or authenticated roles (or public/all roles)\n and (\n role_oids = array[0::oid] -- public (all roles)\n or exists (\n select 1\n from unnest(role_oids) as r\n where r::regrole::text in ('anon', 'authenticated')\n )\n )\n)\nselect\n 'rls_policy_always_true' as name,\n 'RLS Policy Always True' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects RLS policies that use overly permissive expressions like `USING (true)` or `WITH CHECK (true)` for UPDATE, DELETE, or INSERT operations. SELECT policies with `USING (true)` are intentionally excluded as this pattern is often used deliberately for public read access.' as description,\n format(\n 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.',\n schema_name,\n table_name,\n policy_name,\n command,\n case\n when has_permissive_using and has_permissive_with_check then ' (both USING and WITH CHECK are always true)'\n when has_permissive_using then ' (USING clause is always true)'\n when has_permissive_with_check then ' (WITH CHECK clause is always true)'\n else ''\n end,\n array_to_string(roles, ', ')\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table',\n 'policy_name', policy_name,\n 'command', command,\n 'roles', roles,\n 'qual', qual,\n 'with_check', with_check,\n 'permissive_using', has_permissive_using,\n 'permissive_with_check', has_permissive_with_check\n ) as metadata,\n format(\n 'rls_policy_always_true_%s_%s_%s',\n schema_name,\n table_name,\n policy_name\n ) as cache_key\nfrom\n permissive_patterns\nwhere\n has_permissive_using or has_permissive_with_check\norder by\n schema_name,\n table_name,\n policy_name)"; + "set local search_path = '';\n\n(\nwith foreign_keys as (\n select\n cl.relnamespace::regnamespace::text as schema_name,\n cl.relname as table_name,\n cl.oid as table_oid,\n ct.conname as fkey_name,\n ct.conkey as col_attnums\n from\n pg_catalog.pg_constraint ct\n join pg_catalog.pg_class cl -- fkey owning table\n on ct.conrelid = cl.oid\n left join pg_catalog.pg_depend d\n on d.objid = cl.oid\n and d.deptype = 'e'\n where\n ct.contype = 'f' -- foreign key constraints\n and d.objid is null -- exclude tables that are dependencies of extensions\n and cl.relnamespace::regnamespace::text not in (\n 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions'\n )\n),\nindex_ as (\n select\n pi.indrelid as table_oid,\n indexrelid::regclass as index_,\n string_to_array(indkey::text, ' ')::smallint[] as col_attnums\n from\n pg_catalog.pg_index pi\n where\n indisvalid\n)\nselect\n 'unindexed_foreign_keys' as name,\n 'Unindexed foreign keys' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Identifies foreign key constraints without a covering index, which can impact database performance.' as description,\n format(\n 'Table `%s.%s` has a foreign key `%s` without a covering index. This can lead to suboptimal query performance.',\n fk.schema_name,\n fk.table_name,\n fk.fkey_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as remediation,\n jsonb_build_object(\n 'schema', fk.schema_name,\n 'name', fk.table_name,\n 'type', 'table',\n 'fkey_name', fk.fkey_name,\n 'fkey_columns', fk.col_attnums\n ) as metadata,\n format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as cache_key\nfrom\n foreign_keys fk\n left join index_ idx\n on fk.table_oid = idx.table_oid\n and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)]\n left join pg_catalog.pg_depend dep\n on idx.table_oid = dep.objid\n and dep.deptype = 'e'\nwhere\n idx.index_ is null\n and fk.schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude tables owned by extensions\norder by\n fk.schema_name,\n fk.table_name,\n fk.fkey_name)\nunion all\n(\nselect\n 'auth_users_exposed' as name,\n 'Exposed Auth Users' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as description,\n format(\n 'View/Materialized View \"%s\" in the public schema may expose `auth.users` data to anon or authenticated roles.',\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'view',\n 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)\n ) as metadata,\n format('auth_users_exposed_%s_%s', n.nspname, c.relname) as cache_key\nfrom\n -- Identify the oid for auth.users\n pg_catalog.pg_class auth_users_pg_class\n join pg_catalog.pg_namespace auth_users_pg_namespace\n on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid\n and auth_users_pg_class.relname = 'users'\n and auth_users_pg_namespace.nspname = 'auth'\n -- Depends on auth.users\n join pg_catalog.pg_depend d\n on d.refobjid = auth_users_pg_class.oid\n join pg_catalog.pg_rewrite r\n on r.oid = d.objid\n join pg_catalog.pg_class c\n on c.oid = r.ev_class\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n join pg_catalog.pg_class pg_class_auth_users\n on d.refobjid = pg_class_auth_users.oid\nwhere\n d.deptype = 'n'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))\n -- Exclude self\n and c.relname <> '0002_auth_users_exposed'\n -- There are 3 insecure configurations\n and\n (\n -- Materialized views don't support RLS so this is insecure by default\n (c.relkind in ('m')) -- m for materialized view\n or\n -- Standard View, accessible to anon or authenticated that is security_definer\n (\n c.relkind = 'v' -- v for view\n -- Exclude security invoker views\n and not (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n )\n )\n or\n -- Standard View, security invoker, but no RLS enabled on auth.users\n (\n c.relkind in ('v') -- v for view\n -- is security invoker\n and (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n )\n and not pg_class_auth_users.relrowsecurity\n )\n )\ngroup by\n n.nspname,\n c.relname,\n c.oid)\nunion all\n(\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n pc.relrowsecurity as is_rls_active,\n polname as policy_name,\n polpermissive as is_permissive, -- if not, then restrictive\n (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles,\n case polcmd\n when 'r' then 'SELECT'\n when 'a' then 'INSERT'\n when 'w' then 'UPDATE'\n when 'd' then 'DELETE'\n when '*' then 'ALL'\n end as command,\n qual,\n with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n)\nselect\n 'auth_rls_initplan' as name,\n 'Auth RLS Initialization Plan' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if calls to `current_setting()` and `auth.()` in RLS policies are being unnecessarily re-evaluated for each row' as description,\n format(\n 'Table `%s.%s` has a row level security policy `%s` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.()` with `(select auth.())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.',\n schema_name,\n table_name,\n policy_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table'\n ) as metadata,\n format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as cache_key\nfrom\n policies\nwhere\n is_rls_active\n -- NOTE: does not include realtime in support of monitoring policies on realtime.messages\n and schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and (\n -- Example: auth.uid()\n (\n qual like '%auth.uid()%'\n and lower(qual) not like '%select auth.uid()%'\n )\n or (\n qual like '%auth.jwt()%'\n and lower(qual) not like '%select auth.jwt()%'\n )\n or (\n qual like '%auth.role()%'\n and lower(qual) not like '%select auth.role()%'\n )\n or (\n qual like '%auth.email()%'\n and lower(qual) not like '%select auth.email()%'\n )\n or (\n qual like '%current\\_setting(%)%'\n and lower(qual) not like '%select current\\_setting(%)%'\n )\n or (\n with_check like '%auth.uid()%'\n and lower(with_check) not like '%select auth.uid()%'\n )\n or (\n with_check like '%auth.jwt()%'\n and lower(with_check) not like '%select auth.jwt()%'\n )\n or (\n with_check like '%auth.role()%'\n and lower(with_check) not like '%select auth.role()%'\n )\n or (\n with_check like '%auth.email()%'\n and lower(with_check) not like '%select auth.email()%'\n )\n or (\n with_check like '%current\\_setting(%)%'\n and lower(with_check) not like '%select current\\_setting(%)%'\n )\n ))\nunion all\n(\nselect\n 'no_primary_key' as name,\n 'No Primary Key' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as description,\n format(\n 'Table `%s.%s` does not have a primary key',\n pgns.nspname,\n pgc.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as remediation,\n jsonb_build_object(\n 'schema', pgns.nspname,\n 'name', pgc.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'no_primary_key_%s_%s',\n pgns.nspname,\n pgc.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class pgc\n join pg_catalog.pg_namespace pgns\n on pgns.oid = pgc.relnamespace\n left join pg_catalog.pg_index pgi\n on pgi.indrelid = pgc.oid\n left join pg_catalog.pg_depend dep\n on pgc.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n pgc.relkind = 'r' -- regular tables\n and pgns.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n pgc.oid,\n pgns.nspname,\n pgc.relname\nhaving\n max(coalesce(pgi.indisprimary, false)::int) = 0)\nunion all\n(\nselect\n 'unused_index' as name,\n 'Unused Index' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if an index has never been used and may be a candidate for removal.' as description,\n format(\n 'Index `%s` on table `%s.%s` has not been used',\n psui.indexrelname,\n psui.schemaname,\n psui.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as remediation,\n jsonb_build_object(\n 'schema', psui.schemaname,\n 'name', psui.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'unused_index_%s_%s_%s',\n psui.schemaname,\n psui.relname,\n psui.indexrelname\n ) as cache_key\n\nfrom\n pg_catalog.pg_stat_user_indexes psui\n join pg_catalog.pg_index pi\n on psui.indexrelid = pi.indexrelid\n left join pg_catalog.pg_depend dep\n on psui.relid = dep.objid\n and dep.deptype = 'e'\nwhere\n psui.idx_scan = 0\n and not pi.indisunique\n and not pi.indisprimary\n and dep.objid is null -- exclude tables owned by extensions\n and psui.schemaname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n ))\nunion all\n(\nselect\n 'multiple_permissive_policies' as name,\n 'Multiple Permissive Policies' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as description,\n format(\n 'Table `%s.%s` has multiple permissive policies for role `%s` for action `%s`. Policies include `%s`',\n n.nspname,\n c.relname,\n r.rolname,\n act.cmd,\n array_agg(p.polname order by p.polname)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'multiple_permissive_policies_%s_%s_%s_%s',\n n.nspname,\n c.relname,\n r.rolname,\n act.cmd\n ) as cache_key\nfrom\n pg_catalog.pg_policy p\n join pg_catalog.pg_class c\n on p.polrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n join pg_catalog.pg_roles r\n on p.polroles @> array[r.oid]\n or p.polroles = array[0::oid]\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e',\n lateral (\n select x.cmd\n from unnest((\n select\n case p.polcmd\n when 'r' then array['SELECT']\n when 'a' then array['INSERT']\n when 'w' then array['UPDATE']\n when 'd' then array['DELETE']\n when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE']\n else array['ERROR']\n end as actions\n )) x(cmd)\n ) act(cmd)\nwhere\n c.relkind = 'r' -- regular tables\n and p.polpermissive -- policy is permissive\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and r.rolname not like 'pg_%'\n and r.rolname not like 'supabase%admin'\n and not r.rolbypassrls\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relname,\n r.rolname,\n act.cmd\nhaving\n count(1) > 1)\nunion all\n(\nselect\n 'policy_exists_rls_disabled' as name,\n 'Policy Exists RLS Disabled' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as description,\n format(\n 'Table `%s.%s` has RLS policies but RLS is not enabled on the table. Policies include %s.',\n n.nspname,\n c.relname,\n array_agg(p.polname order by p.polname)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'policy_exists_rls_disabled_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_policy p\n join pg_catalog.pg_class c\n on p.polrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'r' -- regular tables\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n -- RLS is disabled\n and not c.relrowsecurity\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relname)\nunion all\n(\nselect\n 'rls_enabled_no_policy' as name,\n 'RLS Enabled No Policy' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as description,\n format(\n 'Table `%s.%s` has RLS enabled, but no policies exist',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_enabled_no_policy_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n left join pg_catalog.pg_policy p\n on p.polrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'r' -- regular tables\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n -- RLS is enabled\n and c.relrowsecurity\n and p.polname is null\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relname)\nunion all\n(\nselect\n 'duplicate_index' as name,\n 'Duplicate Index' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects cases where two ore more identical indexes exist.' as description,\n format(\n 'Table `%s.%s` has identical indexes %s. Drop all except one of them',\n n.nspname,\n c.relname,\n array_agg(pi.indexname order by pi.indexname)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', case\n when c.relkind = 'r' then 'table'\n when c.relkind = 'm' then 'materialized view'\n else 'ERROR'\n end,\n 'indexes', array_agg(pi.indexname order by pi.indexname)\n ) as metadata,\n format(\n 'duplicate_index_%s_%s_%s',\n n.nspname,\n c.relname,\n array_agg(pi.indexname order by pi.indexname)\n ) as cache_key\nfrom\n pg_catalog.pg_indexes pi\n join pg_catalog.pg_namespace n\n on n.nspname = pi.schemaname\n join pg_catalog.pg_class c\n on pi.tablename = c.relname\n and n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind in ('r', 'm') -- tables and materialized views\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relkind,\n c.relname,\n replace(pi.indexdef, pi.indexname, '')\nhaving\n count(*) > 1)\nunion all\n(\nselect\n 'security_definer_view' as name,\n 'Security Definer View' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as description,\n format(\n 'View `%s.%s` is defined with the SECURITY DEFINER property',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'view'\n ) as metadata,\n format(\n 'security_definer_view_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'v'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15\n and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude views owned by extensions\n and not (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n ))\nunion all\n(\nselect\n 'function_search_path_mutable' as name,\n 'Function Search Path Mutable' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects functions where the search_path parameter is not set.' as description,\n format(\n 'Function `%s.%s` has a role mutable search_path',\n n.nspname,\n p.proname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', p.proname,\n 'type', 'function'\n ) as metadata,\n format(\n 'function_search_path_mutable_%s_%s_%s',\n n.nspname,\n p.proname,\n md5(p.prosrc) -- required when function is polymorphic\n ) as cache_key\nfrom\n pg_catalog.pg_proc p\n join pg_catalog.pg_namespace n\n on p.pronamespace = n.oid\n left join pg_catalog.pg_depend dep\n on p.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude functions owned by extensions\n -- Search path not set\n and not exists (\n select 1\n from unnest(coalesce(p.proconfig, '{}')) as config\n where config like 'search_path=%'\n ))\nunion all\n(\nselect\n 'rls_disabled_in_public' as name,\n 'RLS Disabled in Public' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as description,\n format(\n 'Table `%s.%s` is public, but RLS has not been enabled.',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_disabled_in_public_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\nwhere\n c.relkind = 'r' -- regular tables\n -- RLS is disabled\n and not c.relrowsecurity\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n ))\nunion all\n(\nselect\n 'extension_in_public' as name,\n 'Extension in Public' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects extensions installed in the `public` schema.' as description,\n format(\n 'Extension `%s` is installed in the public schema. Move it to another schema.',\n pe.extname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as remediation,\n jsonb_build_object(\n 'schema', pe.extnamespace::regnamespace,\n 'name', pe.extname,\n 'type', 'extension'\n ) as metadata,\n format(\n 'extension_in_public_%s',\n pe.extname\n ) as cache_key\nfrom\n pg_catalog.pg_extension pe\nwhere\n -- plpgsql is installed by default in public and outside user control\n -- confirmed safe\n pe.extname not in ('plpgsql')\n -- Scoping this to public is not optimal. Ideally we would use the postgres\n -- search path. That currently isn't available via SQL. In other lints\n -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that\n -- is not appropriate here as it would evaluate true for the extensions schema\n and pe.extnamespace::regnamespace::text = 'public')\nunion all\n(\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n polname as policy_name,\n qual,\n with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n)\nselect\n 'rls_references_user_metadata' as name,\n 'RLS references user metadata' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as description,\n format(\n 'Table `%s.%s` has a row level security policy `%s` that references Supabase Auth `user_metadata`. `user_metadata` is editable by end users and should never be used in a security context.',\n schema_name,\n table_name,\n policy_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table'\n ) as metadata,\n format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as cache_key\nfrom\n policies\nwhere\n schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and (\n -- Example: auth.jwt() -> 'user_metadata'\n -- False positives are possible, but it isn't practical to string match\n -- If false positive rate is too high, this expression can iterate\n qual like '%auth.jwt()%user_metadata%'\n or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'\n or with_check like '%auth.jwt()%user_metadata%'\n or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'\n ))\nunion all\n(\nselect\n 'materialized_view_in_api' as name,\n 'Materialized View in API' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects materialized views that are accessible over the Data APIs.' as description,\n format(\n 'Materialized view `%s.%s` is selectable by anon or authenticated roles',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'materialized view'\n ) as metadata,\n format(\n 'materialized_view_in_api_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'm'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null)\nunion all\n(\nselect\n 'foreign_table_in_api' as name,\n 'Foreign Table in API' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as description,\n format(\n 'Foreign table `%s.%s` is accessible over APIs',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'foreign table'\n ) as metadata,\n format(\n 'foreign_table_in_api_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'f'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null)\nunion all\n(\nselect\n 'unsupported_reg_types' as name,\n 'Unsupported reg types' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as description,\n format(\n 'Table `%s.%s` has a column `%s` with unsupported reg* type `%s`.',\n n.nspname,\n c.relname,\n a.attname,\n t.typname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'column', a.attname,\n 'type', 'table'\n ) as metadata,\n format(\n 'unsupported_reg_types_%s_%s_%s',\n n.nspname,\n c.relname,\n a.attname\n ) AS cache_key\nfrom\n pg_catalog.pg_attribute a\n join pg_catalog.pg_class c\n on a.attrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n join pg_catalog.pg_type t\n on a.atttypid = t.oid\n join pg_catalog.pg_namespace tn\n on t.typnamespace = tn.oid\nwhere\n tn.nspname = 'pg_catalog'\n and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure')\n and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium'))\nunion all\n(\nselect\n 'insecure_queue_exposed_in_api' as name,\n 'Insecure Queue Exposed in API' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where an insecure Queue is exposed over Data APIs' as description,\n format(\n 'Table `%s.%s` is public, but RLS has not been enabled.',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_disabled_in_public_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\nwhere\n c.relkind in ('r', 'I') -- regular or partitioned tables\n and not c.relrowsecurity -- RLS is disabled\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = 'pgmq' -- tables in the pgmq schema\n and c.relname like 'q_%' -- only queue tables\n -- Constant requirements\n and 'pgmq_public' = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))))\nunion all\n(\nwith constants as (\n select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma\n),\n\nbloat_info as (\n select\n ma,\n bs,\n schemaname,\n tablename,\n (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr,\n (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2\n from (\n select\n schemaname,\n tablename,\n hdr,\n ma,\n bs,\n sum((1 - null_frac) * avg_width) as datawidth,\n max(null_frac) as maxfracsum,\n hdr + (\n select 1 + count(*) / 8\n from pg_stats s2\n where\n null_frac <> 0\n and s2.schemaname = s.schemaname\n and s2.tablename = s.tablename\n ) as nullhdr\n from pg_stats s, constants\n group by 1, 2, 3, 4, 5\n ) as foo\n),\n\ntable_bloat as (\n select\n schemaname,\n tablename,\n cc.relpages,\n bs,\n ceil((cc.reltuples * ((datahdr + ma -\n (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta\n from\n bloat_info\n join pg_class cc\n on cc.relname = bloat_info.tablename\n join pg_namespace nn\n on cc.relnamespace = nn.oid\n and nn.nspname = bloat_info.schemaname\n and nn.nspname <> 'information_schema'\n where\n cc.relkind = 'r'\n and cc.relam = (select oid from pg_am where amname = 'heap')\n),\n\nbloat_data as (\n select\n 'table' as type,\n schemaname,\n tablename as object_name,\n round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat,\n case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste\n from\n table_bloat\n)\n\nselect\n 'table_bloat' as name,\n 'Table Bloat' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as description,\n format(\n 'Table `%s`.`%s` has excessive bloat',\n bloat_data.schemaname,\n bloat_data.object_name\n ) as detail,\n 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as remediation,\n jsonb_build_object(\n 'schema', bloat_data.schemaname,\n 'name', bloat_data.object_name,\n 'type', bloat_data.type\n ) as metadata,\n format(\n 'table_bloat_%s_%s',\n bloat_data.schemaname,\n bloat_data.object_name\n ) as cache_key\nfrom\n bloat_data\nwhere\n bloat > 70.0\n and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB\norder by\n schemaname,\n object_name)\nunion all\n(\nselect\n 'fkey_to_auth_unique' as name,\n 'Foreign Key to Auth Unique Constraint' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects user defined foreign keys to unique constraints in the auth schema.' as description,\n format(\n 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint',\n n.nspname, -- referencing schema\n c_rel.relname, -- referencing table\n c.conname -- fkey name\n ) as detail,\n 'Drop the foreign key constraint that references the auth schema.' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c_rel.relname,\n 'foreign_key', c.conname\n ) as metadata,\n format(\n 'fkey_to_auth_unique_%s_%s_%s',\n n.nspname, -- referencing schema\n c_rel.relname, -- referencing table\n c.conname\n ) as cache_key\nfrom\n pg_catalog.pg_constraint c\n join pg_catalog.pg_class c_rel\n on c.conrelid = c_rel.oid\n join pg_catalog.pg_namespace n\n on c_rel.relnamespace = n.oid\n join pg_catalog.pg_class ref_rel\n on c.confrelid = ref_rel.oid\n join pg_catalog.pg_namespace cn\n on ref_rel.relnamespace = cn.oid\n join pg_catalog.pg_index i\n on c.conindid = i.indexrelid\nwhere c.contype = 'f'\n and cn.nspname = 'auth'\n and i.indisunique\n and not i.indisprimary)\nunion all\n(\nselect\n 'extension_versions_outdated' as name,\n 'Extension Versions Outdated' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects extensions that are not using the default (recommended) version.' as description,\n format(\n 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.',\n ext.name,\n ext.installed_version,\n ext.default_version\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as remediation,\n jsonb_build_object(\n 'extension_name', ext.name,\n 'installed_version', ext.installed_version,\n 'default_version', ext.default_version\n ) as metadata,\n format(\n 'extension_versions_outdated_%s_%s',\n ext.name,\n ext.installed_version\n ) as cache_key\nfrom\n pg_catalog.pg_available_extensions ext\njoin\n -- ignore versions not in pg_available_extension_versions\n -- e.g. residue of pg_upgrade\n pg_catalog.pg_available_extension_versions extv\n on extv.name = ext.name and extv.installed\nwhere\n ext.installed_version is not null\n and ext.default_version is not null\n and ext.installed_version != ext.default_version\norder by\n ext.name)\nunion all\n(\n-- Detects tables exposed via API that contain columns with sensitive names\n-- Inspired by patterns from security scanners that detect PII/credential exposure\nwith sensitive_patterns as (\n select unnest(array[\n -- Authentication & Credentials\n 'password', 'passwd', 'pwd', 'passphrase',\n 'secret', 'secret_key', 'private_key', 'api_key', 'apikey',\n 'auth_key', 'token', 'jwt', 'access_token', 'refresh_token',\n 'oauth_token', 'session_token', 'bearer_token', 'auth_code',\n 'session_id', 'session_key', 'session_secret',\n 'recovery_code', 'backup_code', 'verification_code',\n 'otp', 'two_factor', '2fa_secret', '2fa_code',\n -- Personal Identifiers\n 'ssn', 'social_security', 'social_security_number',\n 'driver_license', 'drivers_license', 'license_number',\n 'passport_number', 'passport_id', 'national_id', 'tax_id',\n -- Financial Information\n 'credit_card', 'card_number', 'cvv', 'cvc', 'cvn',\n 'bank_account', 'account_number', 'routing_number',\n 'iban', 'swift_code', 'bic',\n -- Health & Medical\n 'health_record', 'medical_record', 'patient_id',\n 'insurance_number', 'health_insurance', 'medical_insurance',\n 'treatment',\n -- Device Identifiers\n 'mac_address', 'macaddr', 'imei', 'device_uuid',\n -- Digital Keys & Certificates\n 'pgp_key', 'gpg_key', 'ssh_key', 'certificate',\n 'license_key', 'activation_key',\n -- Biometric Data\n 'facial_recognition'\n ]) as pattern\n),\nexposed_tables as (\n select\n n.nspname as schema_name,\n c.relname as table_name,\n c.oid as table_oid\n from\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n where\n c.relkind = 'r' -- regular tables\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n -- Only flag tables without RLS enabled\n and not c.relrowsecurity\n),\nsensitive_columns as (\n select\n et.schema_name,\n et.table_name,\n a.attname as column_name,\n sp.pattern as matched_pattern\n from\n exposed_tables et\n join pg_catalog.pg_attribute a\n on a.attrelid = et.table_oid\n and a.attnum > 0\n and not a.attisdropped\n cross join sensitive_patterns sp\n where\n -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants\n replace(lower(a.attname), '-', '_') = sp.pattern\n)\nselect\n 'sensitive_columns_exposed' as name,\n 'Sensitive Columns Exposed' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.' as description,\n format(\n 'Table `%s.%s` is exposed via API without RLS and contains potentially sensitive column(s): %s. This may lead to data exposure.',\n schema_name,\n table_name,\n string_agg(distinct column_name, ', ' order by column_name)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table',\n 'sensitive_columns', array_agg(distinct column_name order by column_name),\n 'matched_patterns', array_agg(distinct matched_pattern order by matched_pattern)\n ) as metadata,\n format(\n 'sensitive_columns_exposed_%s_%s',\n schema_name,\n table_name\n ) as cache_key\nfrom\n sensitive_columns\ngroup by\n schema_name,\n table_name\norder by\n schema_name,\n table_name)\nunion all\n(\n-- Detects RLS policies that are overly permissive (e.g., USING (true), USING (1=1))\n-- These policies effectively disable row-level security while giving a false sense of security\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n pc.relrowsecurity as is_rls_active,\n pa.polname as policy_name,\n pa.polpermissive as is_permissive,\n pa.polroles as role_oids,\n (select array_agg(r::regrole::text) from unnest(pa.polroles) as x(r)) as roles,\n case pa.polcmd\n when 'r' then 'SELECT'\n when 'a' then 'INSERT'\n when 'w' then 'UPDATE'\n when 'd' then 'DELETE'\n when '*' then 'ALL'\n end as command,\n pb.qual,\n pb.with_check,\n -- Normalize expressions by removing whitespace and lowercasing\n replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\\n', ''), E'\\t', '') as normalized_qual,\n replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\\n', ''), E'\\t', '') as normalized_with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n where\n pc.relkind = 'r' -- regular tables\n and nsp.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n),\npermissive_patterns as (\n select\n p.*,\n -- Check for always-true USING clause patterns\n -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE\n case when (\n command in ('UPDATE', 'DELETE', 'ALL')\n and (\n normalized_qual in ('true', '(true)', '1=1', '(1=1)')\n -- Empty or null qual on permissive policy means allow all\n or (qual is null and is_permissive)\n )\n ) then true else false end as has_permissive_using,\n -- Check for always-true WITH CHECK clause patterns\n case when (\n normalized_with_check in ('true', '(true)', '1=1', '(1=1)')\n -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on)\n or (with_check is null and is_permissive and command = 'INSERT')\n -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes\n or (with_check is null and is_permissive and command in ('UPDATE', 'ALL')\n and normalized_qual in ('true', '(true)', '1=1', '(1=1)'))\n ) then true else false end as has_permissive_with_check\n from\n policies p\n where\n -- Only check tables with RLS enabled (otherwise it's a different lint)\n is_rls_active\n -- Only check permissive policies (restrictive policies with true are less dangerous)\n and is_permissive\n -- Only flag policies that apply to anon or authenticated roles (or public/all roles)\n and (\n role_oids = array[0::oid] -- public (all roles)\n or exists (\n select 1\n from unnest(role_oids) as r\n where r::regrole::text in ('anon', 'authenticated')\n )\n )\n)\nselect\n 'rls_policy_always_true' as name,\n 'RLS Policy Always True' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects RLS policies that use overly permissive expressions like `USING (true)` or `WITH CHECK (true)` for UPDATE, DELETE, or INSERT operations. SELECT policies with `USING (true)` are intentionally excluded as this pattern is often used deliberately for public read access.' as description,\n format(\n 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.',\n schema_name,\n table_name,\n policy_name,\n command,\n case\n when has_permissive_using and has_permissive_with_check then ' (both USING and WITH CHECK are always true)'\n when has_permissive_using then ' (USING clause is always true)'\n when has_permissive_with_check then ' (WITH CHECK clause is always true)'\n else ''\n end,\n array_to_string(roles, ', ')\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table',\n 'policy_name', policy_name,\n 'command', command,\n 'roles', roles,\n 'qual', qual,\n 'with_check', with_check,\n 'permissive_using', has_permissive_using,\n 'permissive_with_check', has_permissive_with_check\n ) as metadata,\n format(\n 'rls_policy_always_true_%s_%s_%s',\n schema_name,\n table_name,\n policy_name\n ) as cache_key\nfrom\n permissive_patterns\nwhere\n has_permissive_using or has_permissive_with_check\norder by\n schema_name,\n table_name,\n policy_name)"; /** * Splits the embedded SQL into [setup, query] on the first `";\n\n"`, diff --git a/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md index 5987e44efc..bd930c4242 100644 --- a/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | --------- | ------ | -| `/.branches//` (directory) | directory | always | +| Path | Format | When | +| --------------------------------------------------------- | --------- | ------ | +| `/supabase/.branches//` (directory) | directory | always | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md index 6b392e7110..97c31f4eb1 100644 --- a/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | --------- | ------- | -| `/.branches//` (directory) | directory | removed | +| Path | Format | When | +| --------------------------------------------------------- | --------- | ------- | +| `/supabase/.branches//` (directory) | directory | removed | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md index 14e1803271..bbbe030daf 100644 --- a/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md @@ -48,4 +48,4 @@ Not applicable. ## Notes - Deprecated in the Go CLI: use `branches list` instead. -- This is a local-only operation listing branches in `/.branches/`. +- This is a local-only operation listing branches in `/supabase/.branches/`. diff --git a/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md index 5cce0f2423..aa1cd0cbf5 100644 --- a/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ------------------------------------ | ---------- | ------ | -| `/.supabase/current-branch` | plain text | always | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ------ | +| `/supabase/.branches/_current_branch` | plain text | always | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index c7dcdb5fad..61fb818dbc 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -8,7 +8,6 @@ import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; -import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts"; @@ -27,6 +26,7 @@ import { legacyGetMigrationPath, } from "../../../shared/legacy-migration-file.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; +import { legacyWritePgDeltaMigrations } from "../shared/legacy-pgdelta-migrations.write.ts"; import { type LegacyPgDeltaContext, legacyDiffPgDelta } from "../shared/legacy-pgdelta.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; @@ -44,23 +44,6 @@ import { const warnDiff = `WARNING: The diff tool is not foolproof, so you may need to manually rearrange and modify the generated migration. Run ${legacyAqua("supabase db reset")} to verify that the new migration does not generate errors.`; -/** Builds a plain Postgres URL from a resolved connection (Go's `ToPostgresURL`). */ -const connToUrl = (conn: LegacyPgConnInput): string => - legacyToPostgresURL({ - host: conn.host, - port: conn.port, - user: conn.user, - password: conn.password, - database: conn.database, - ...(conn.options !== undefined ? { options: conn.options } : {}), - ...(conn.runtimeParams !== undefined ? { runtimeParams: conn.runtimeParams } : {}), - // Preserve a `--db-url` connect_timeout; Go's ToPostgresURL serializes the - // parsed ConnectTimeout (`connect.go`), defaulting to 10 only when unset. - ...(conn.connectTimeoutSeconds !== undefined - ? { connectTimeoutSeconds: conn.connectTimeoutSeconds } - : {}), - }); - /** * Rebuilds the `db diff` argv for the pgAdmin / pg-schema delegate path. Flags * stay flags (the Go-proxy channel-parity rule). The explicit `--from`/`--to` and @@ -234,7 +217,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy mergedLinkedRef = ref2; cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref2); } - return connToUrl(resolved.conn); + return legacyToPostgresURL(resolved.conn); } case "migrations": return yield* seam.exportCatalog({ @@ -361,7 +344,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }); const linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); if (linkedRef !== undefined) linkedRefForCache = linkedRef; - const targetUrl = connToUrl(resolved.conn); + const targetUrl = legacyToPostgresURL(resolved.conn); // Read config with the resolved linked ref so a matching `[remotes.]` // block merges before the engine/format/runtime are read — Go loads config @@ -406,7 +389,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy projectRef: connType === "linked" ? linkedRef : undefined, }); - const out = yield* Effect.gen(function* () { + const diffResult = yield* Effect.gen(function* () { const target = shadow.targetUrlOverride ?? targetUrl; yield* output.raw( flags.schema.length > 0 @@ -421,15 +404,22 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy schema: flags.schema, formatOptions, }); - return result.sql; + // Keep the per-unit plan files so a multi-unit plan can be written as one + // migration file each (Go's `DatabaseDiff.Files`); `sql` stays the flattened + // join for stdout review + machine payloads. + return { sql: result.sql, files: result.files }; } - return yield* legacyDiffMigra(ctx, { + const sql = yield* legacyDiffMigra(ctx, { source: shadow.sourceUrl, target, schema: flags.schema, connectOptions: { isLocal: resolved.isLocal, dnsResolver }, }); + // The migra engine has no execution-aware plan units, so it always writes a + // single migration file (Go's `SaveDiff` single-file path). + return { sql, files: undefined }; }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + const out = diffResult.sql; // Detect the branch from the resolved workdir, not the caller's CWD: Go // chdirs into --workdir in PersistentPreRunE before GetGitBranch @@ -444,27 +434,46 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // Go's `SaveDiff` (`pgadmin.go:20`) + the drop-statement warning (`diff.go:44`). const engine = useDelta ? "pg-delta" : "migra"; const drops = legacyFindDropStatements(out); - let writtenFile: string | null = null; + const writtenFiles: Array = []; if (out.length < 2) { yield* output.raw("No schema changes found\n", "stderr"); // Go's `SaveDiff` gates the file write on `len(file) > 0` (`pgadmin.go`), so // an empty `--file=""` (e.g. an unset shell var) falls through to stdout // rather than writing a `_.sql` migration with no name. } else if (Option.isSome(flags.file) && flags.file.value.length > 0) { - const timestamp = legacyFormatMigrationTimestamp(yield* Clock.currentTimeMillis); - const migrationPath = legacyGetMigrationPath( - path, - cliConfig.workdir, - timestamp, - flags.file.value, - ); - yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( - Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })), - ); - yield* fs - .writeFileString(migrationPath, out) - .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); - writtenFile = migrationPath; + const fileName = flags.file.value; + // A pg-delta plan that crosses a transaction boundary yields more than one + // ordered unit; writing them into a single migration file would later fail + // when `db push`/`reset` applies it as one transaction. Write one migration + // file per unit in that case via the shared writer (Go's + // `WritePgDeltaMigrations`, `internal/db/diff/pgdelta_migrations.go`): each + // file appends the unit name and gets a strictly increasing timestamp, the + // full set is collision-checked against existing migrations, and every file is + // written exclusively so a pre-existing migration is never overwritten. A + // single-unit plan (and the migra engine) keeps the exact `_.sql` + // file (Go's `utils.WriteFile`), byte-identical to before. + const planFiles = diffResult.files ?? []; + if (planFiles.length > 1) { + const writtenUnits = yield* legacyWritePgDeltaMigrations(fs, path, { + workdir: cliConfig.workdir, + baseMillis: yield* Clock.currentTimeMillis, + name: fileName, + files: planFiles.map((file) => ({ name: file.name, sql: file.sql })), + }).pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); + for (const unit of writtenUnits) writtenFiles.push(unit.path); + } else { + const timestamp = legacyFormatMigrationTimestamp(yield* Clock.currentTimeMillis); + const migrationPath = legacyGetMigrationPath(path, cliConfig.workdir, timestamp, fileName); + // Create parent dirs per written path (mirroring Go's `utils.WriteFile`), so a + // nested `--file snapshots/remote` name creates `_snapshots/` first. + yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( + Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })), + ); + yield* fs + .writeFileString(migrationPath, out) + .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); + writtenFiles.push(migrationPath); + } yield* output.raw(`${warnDiff}\n`, "stderr"); } else if (output.format === "text") { yield* output.raw(`${out}\n`); @@ -479,7 +488,12 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy if (output.format !== "text") { yield* output.success("Diff complete.", { diff: out, - file: writtenFile, + // `file` keeps the first written path for released consumers that read the + // string field (null when nothing was written); `files` lists EVERY written + // migration path in write order (a pg-delta plan writes one file per unit), + // mirroring pull's `schemaFiles` so machine callers see all of them. + file: writtenFiles[0] ?? null, + files: writtenFiles, schemas: flags.schema, engine, dropStatements: drops, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 0278e7e41e..f4e6fb4ea6 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -1,10 +1,11 @@ -import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; import { + legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, mockLegacyTelemetryStateTracked, @@ -35,10 +36,15 @@ interface SetupOpts { readonly isLocal?: boolean; readonly linkedRef?: string; readonly diffSql?: string; + // When set, the pg-delta edge mock emits a multi-unit plan envelope (one file + // per entry) instead of the single-unit wrap of `diffSql`. + readonly diffFiles?: ReadonlyArray<{ readonly name: string; readonly sql: string }>; readonly targetOverride?: string; readonly oom?: boolean; // edge-runtime OOMs; the bash fallback returns `diffSql` readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run readonly networkId?: string; // --network-id value forwarded to docker runs + // When set, the Nth `writeFileString` fails, exercising cleanup-on-failure. + readonly failWriteOnCall?: number; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -87,7 +93,28 @@ function setup(workdir: string, opts: SetupOpts = {}) { new LegacyEdgeRuntimeScriptError({ message: "Fatal JavaScript out of memory" }), ); } - return Effect.succeed({ stdout: opts.diffSql ?? "", stderr: "" }); + const diffSql = opts.diffSql ?? ""; + // The pg-delta diff script (uniquely identified by `renderPlanFiles`) prints a + // JSON envelope with one file per plan unit; wrap the test's raw SQL into a + // single-unit envelope so `legacyDiffPgDelta` parses it. The migra script + // returns raw SQL unchanged. + const isPgDelta = runOpts.script.includes("renderPlanFiles"); + const planFiles = + opts.diffFiles !== undefined + ? opts.diffFiles.map((file, i) => ({ + order: i + 1, + name: file.name, + transactionMode: "transactional", + sql: file.sql, + })) + : diffSql.length > 0 + ? [{ order: 1, name: "schema_changes", transactionMode: "transactional", sql: diffSql }] + : []; + const stdout = + isPgDelta && planFiles.length > 0 + ? JSON.stringify({ version: 1, files: planFiles }) + : diffSql; + return Effect.succeed({ stdout, stderr: "" }); }, }); @@ -141,7 +168,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); - const layer = Layer.mergeAll( + const baseLayer = Layer.mergeAll( out.layer, telemetry.layer, cache.layer, @@ -164,6 +191,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockRuntimeInfo(), BunServices.layer, ); + // Merged last so its `FileSystem` overrides `BunServices` (last-wins); `Path` + // still resolves from `BunServices`. + const layer = + opts.failWriteOnCall === undefined + ? baseLayer + : Layer.merge(baseLayer, legacyFailWriteStringOnNthCallFsLayer(opts.failWriteOnCall)); return { layer, @@ -438,6 +471,122 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("writes one migration file per unit for a multi-unit pg-delta plan", () => { + // A pg-delta plan that crosses a transaction boundary yields more than one + // ordered unit; writing them into one migration would fail when db push/reset + // applies it as a single transaction. Each unit becomes its own file (Go's + // WritePgDeltaMigrations), named `_` with strictly increasing + // timestamps, and the machine payload's `files` lists them all. + const s = setup(tmp.current, { + format: "json", + diffFiles: [ + { name: "schema_changes", sql: "alter type mood add value 'ok';" }, + { name: "after_enum_values", sql: "insert into t values ('ok');" }, + ], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), file: Option.some("my_diff") })); + const dir = join(tmp.current, "supabase", "migrations"); + const files = readdirSync(dir).sort(); + expect(files).toHaveLength(2); + expect(files[0]).toMatch(/^\d{14}_my_diff_schema_changes\.sql$/); + expect(files[1]).toMatch(/^\d{14}_my_diff_after_enum_values\.sql$/); + // Each unit's file carries only that unit's SQL, terminated with a newline. + expect(readFileSync(join(dir, files[0]!), "utf8")).toBe("alter type mood add value 'ok';\n"); + const success = s.out.messages.find((m) => m.type === "success"); + const data = success?.data as { file: string; files: ReadonlyArray }; + expect(data.files).toHaveLength(2); + // `file` stays the first written path for released string-field consumers. + expect(data.file).toBe(data.files[0]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("creates nested parent directories for a nested single-unit --file name", () => { + // `db diff -f snapshots/remote` must create the `_snapshots/` parent dir + // before writing, mirroring Go's `utils.WriteFile`. + const s = setup(tmp.current, { diffSql: "create table g ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ file: Option.some("snapshots/remote") })); + const migrationsRoot = join(tmp.current, "supabase", "migrations"); + const dirs = readdirSync(migrationsRoot); + expect(dirs).toHaveLength(1); + expect(dirs[0]).toMatch(/^\d{14}_snapshots$/); + expect(readdirSync(join(migrationsRoot, dirs[0]!))).toEqual(["remote.sql"]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("creates nested parent directories for a nested multi-unit --file name", () => { + const s = setup(tmp.current, { + format: "json", + diffFiles: [ + { name: "schema_changes", sql: "alter type mood add value 'ok';" }, + { name: "after_enum_values", sql: "insert into t values ('ok');" }, + ], + }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), file: Option.some("snapshots/remote") }), + ); + const success = s.out.messages.find((m) => m.type === "success"); + const data = success?.data as { files: ReadonlyArray }; + expect(data.files).toHaveLength(2); + for (const written of data.files) expect(existsSync(written)).toBe(true); + expect(data.files[0]).toMatch(/\d{14}_snapshots\/remote_schema_changes\.sql$/u); + expect(data.files[1]).toMatch(/\d{14}_snapshots\/remote_after_enum_values\.sql$/u); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("bumps the version set when a target migration file already exists", () => { + // The full generated set is collision-checked before writing; if any target + // exists the base advances one second so the new files stay strictly ascending + // AND never overwrite the pre-existing migration. + const s = setup(tmp.current, { + format: "json", + diffFiles: [ + { name: "schema_changes", sql: "a" }, + { name: "after_enum_values", sql: "b" }, + ], + }); + return Effect.gen(function* () { + const dir = join(tmp.current, "supabase", "migrations"); + mkdirSync(dir, { recursive: true }); + // TestClock starts at epoch 0, so the first version the writer tries is + // 19700101000000; pre-seed a colliding file at that version. + const clashing = join(dir, "19700101000000_my_diff_schema_changes.sql"); + writeFileSync(clashing, "-- pre-existing\n"); + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), file: Option.some("my_diff") })); + expect(readdirSync(dir).sort()).toEqual([ + "19700101000000_my_diff_schema_changes.sql", + "19700101000001_my_diff_schema_changes.sql", + "19700101000002_my_diff_after_enum_values.sql", + ]); + // The pre-existing file was never overwritten. + expect(readFileSync(clashing, "utf8")).toBe("-- pre-existing\n"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("removes already-written unit files when a later unit write fails", () => { + // A mid-loop write failure best-effort removes every file this invocation + // already wrote, so no partial multi-file migration is left behind. + const s = setup(tmp.current, { + format: "json", + failWriteOnCall: 2, + diffFiles: [ + { name: "schema_changes", sql: "a" }, + { name: "after_enum_values", sql: "b" }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), file: Option.some("my_diff") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const dir = join(tmp.current, "supabase", "migrations"); + const remaining = existsSync(dir) ? readdirSync(dir) : []; + expect(remaining).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("explicit --from local --to linked prints the diff to stdout", () => { const s = setup(tmp.current, { isLocal: false, diffSql: "create table e ();\n" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index 0f72399b85..0a51f63b36 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -18,6 +18,7 @@ import { } from "../../../shared/legacy-connect-errors.ts"; import { legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { cobraMutuallyExclusiveErrorMessage } from "../../../../shared/cli/cobra-flag-groups.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import type { LegacyDbDumpFlags } from "./dump.command.ts"; import { @@ -42,15 +43,16 @@ import { /** * Mutually-exclusive flag groups, in cobra's check order (it sorts the joined - * group keys alphabetically — `apps/cli-go/cmd/db.go:434,436,441,445`). The `key` - * preserves the registration order used in the error's `[group]`, while the set - * of violating flags is alphabetised in the message (cobra `sort.Strings(set)`). + * group keys alphabetically — `apps/cli-go/cmd/db.go:434,436,441,445`). Each + * group's flags are in registration order, matching cobra's `[group]` in the + * error text; the set of violating flags is alphabetised separately by + * `cobraMutuallyExclusiveErrorMessage` (cobra `sort.Strings(set)`). */ const LEGACY_DUMP_EXCLUSIVE_GROUPS = [ - { key: "db-url linked local", flags: ["db-url", "linked", "local"] }, - { key: "keep-comments data-only", flags: ["keep-comments", "data-only"] }, - { key: "role-only data-only", flags: ["role-only", "data-only"] }, - { key: "schema role-only", flags: ["schema", "role-only"] }, + ["db-url", "linked", "local"], + ["keep-comments", "data-only"], + ["role-only", "data-only"], + ["schema", "role-only"], ] as const; const DUMP_FILE_MODE = 0o644; @@ -126,11 +128,11 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy } }; for (const group of LEGACY_DUMP_EXCLUSIVE_GROUPS) { - const set = group.flags.filter(isSet); + const set = group.filter(isSet); if (set.length > 1) { return yield* Effect.fail( new LegacyDbDumpMutuallyExclusiveFlagsError({ - message: `if any flags in the group [${group.key}] are set none of the others can be; [${[...set].sort().join(" ")}] were all set`, + message: cobraMutuallyExclusiveErrorMessage(group, set), }), ); } diff --git a/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md index f76f966c3c..49f2aac8ed 100644 --- a/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md @@ -12,15 +12,18 @@ Native TypeScript port of Go's `internal/db/lint`. ## Files Written -| Path | Format | When | -| ---------------------------- | ------ | ---------------------------------------------------- | -| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush, Go `PersistentPostRun`) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `--linked` only, via `LegacyLinkedProjectCache.cache` (Go's `ensureProjectGroupsCached`, `cmd/root.go:174,212-234`) | +| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush, Go `PersistentPostRun`) | No user data is written: the lint runs inside a transaction that is **always rolled back** (`BEGIN` … `ROLLBACK`), matching Go — including `CREATE EXTENSION plpgsql_check`, which is issued on the same connection inside -the open transaction and so is rolled back too. `db lint` does not write the -linked-project cache (it has no `LegacyLinkedProjectCache` dependency). +the open transaction and so is rolled back too. `db lint` DOES write the +linked-project cache when run with `--linked` (matching Go's +`ensureProjectGroupsCached`); the default `--local` and `--db-url` paths never +populate a project ref, so no cache write occurs there. ## API Routes diff --git a/apps/cli/src/legacy/commands/db/lint/lint.command.ts b/apps/cli/src/legacy/commands/db/lint/lint.command.ts index e97a625bc7..f754b6f42c 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.command.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.command.ts @@ -54,9 +54,10 @@ export const legacyDbLintCommand = Command.make("lint", config).pipe( level: flags.level, "fail-on": flags.failOn, }, - // Go records utils.EnumFlag values verbatim (cmd/root_analytics.go:88-116). + // level/fail-on are Flag.choice and are auto-detected as safe via + // `config` below (Go's isEnumFlag, cmd/root_analytics.go:110-116). // --schema stays redacted: it's a []string slice flag in Go, not an EnumFlag. - safeFlags: ["level", "fail-on"], + config, // Go's changedFlags() uses pflag Visit, which reports the canonical // `schema` name even for the `-s` shorthand (cmd/db.go:506); map it so // `db lint -s public` records the schema flag in telemetry. diff --git a/apps/cli/src/legacy/commands/db/pull/pull.command.ts b/apps/cli/src/legacy/commands/db/pull/pull.command.ts index d53964fab2..d024c5e789 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.command.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.command.ts @@ -84,6 +84,7 @@ export const legacyDbPullCommand = Command.make("pull", config).pipe( password: flags.password, }, aliases: { s: "schema", p: "password" }, + config, }), withJsonErrorHandling, ), diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index e95fc934f2..7334bbfbe1 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -44,6 +44,7 @@ import { legacyShouldUsePgDelta, } from "../shared/legacy-diff-engine.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; +import { legacyWritePgDeltaMigrations } from "../shared/legacy-pgdelta-migrations.write.ts"; import { type LegacyDumpOptions, legacyBuildSchemaDumpEnv } from "../shared/legacy-pg-dump.env.ts"; import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; import { @@ -90,23 +91,6 @@ const DEPRECATION_LINE = /** Migration-file mode for the initial pg_dump seed (Go's `OpenFile(..., 0644)`). */ const MIGRATION_FILE_MODE = 0o644; -/** Builds a plain Postgres URL from a resolved connection (Go's `ToPostgresURL`). */ -const connToUrl = (conn: LegacyPgConnInput): string => - legacyToPostgresURL({ - host: conn.host, - port: conn.port, - user: conn.user, - password: conn.password, - database: conn.database, - ...(conn.options !== undefined ? { options: conn.options } : {}), - ...(conn.runtimeParams !== undefined ? { runtimeParams: conn.runtimeParams } : {}), - // Preserve a `--db-url` connect_timeout; Go's ToPostgresURL serializes the - // parsed ConnectTimeout (`connect.go`), defaulting to 10 only when unset. - ...(conn.connectTimeoutSeconds !== undefined - ? { connectTimeoutSeconds: conn.connectTimeoutSeconds } - : {}), - }); - /** Rebuilds the `db pull` argv for the Go-delegated `--experimental` structured-dump branch. */ const rebuildDelegateArgs = (flags: LegacyDbPullFlags): Array => { const args = ["db", "pull"]; @@ -232,7 +216,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }); const linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); if (linkedRef !== undefined) linkedRefForCache = linkedRef; - const targetUrl = connToUrl(resolved.conn); + const targetUrl = legacyToPostgresURL(resolved.conn); // Reload config with the resolved linked ref so a matching `[remotes.]` // block merges before the engine/format/runtime/declarative paths are read — @@ -293,7 +277,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy .pipe(Effect.orElseSucceed(() => Option.none())); if (Option.isSome(pooler)) { yield* legacyEmitPoolerFallbackWarning(resolved.conn.host); - return yield* attempt(connToUrl(pooler.value)); + return yield* attempt(legacyToPostgresURL(pooler.value)); } } return yield* Effect.fail(error); @@ -428,7 +412,8 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy } // Migration-file path (Go's `pull.run`). - const timestamp = legacyFormatMigrationTimestamp(yield* Clock.currentTimeMillis); + const nowMillis = yield* Clock.currentTimeMillis; + const timestamp = legacyFormatMigrationTimestamp(nowMillis); const migrationPath = legacyGetMigrationPath(path, cliConfig.workdir, timestamp, name); const remote = yield* legacyListRemoteMigrations(session); @@ -620,6 +605,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }); return { sql: result.sql, + files: result.files, capture: debug ? { sourceCatalog, stderr: result.stderr } : undefined, }; } @@ -629,7 +615,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy schema: diffSchema, connectOptions: { isLocal: resolved.isLocal, dnsResolver }, }); - return { sql, capture: undefined }; + return { sql, files: undefined, capture: undefined }; }), ); }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); @@ -678,55 +664,87 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ); } - if (!diffEmpty) { - if (seededFromDump) { - // Append the migra diff to the dump-seeded file (Go's `diffRemoteSchema` - // opens the migration file `O_APPEND`, `pull.go:191`). - yield* Effect.scoped( - Effect.gen(function* () { - const file = yield* fs.open(migrationPath, { flag: "a" }).pipe( - Effect.mapError( - (cause) => - new LegacyDbPullWriteError({ - message: `failed to open migration file: ${cause.message}`, - }), - ), - ); - yield* file.writeAll(new TextEncoder().encode(out)).pipe( - Effect.mapError( - (cause) => - new LegacyDbPullWriteError({ - message: `failed to write migration file: ${cause.message}`, - }), - ), - ); - }), - ); - } else { - yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( - Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), - ); - yield* fs.writeFileString(migrationPath, out).pipe( - Effect.mapError( - (cause) => - new LegacyDbPullWriteError({ - message: `failed to write migration file: ${cause.message}`, - }), - ), + // 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 + // execution-aware plan unit. + const writtenMigrations: Array<{ path: string; version: string }> = []; + if (usePgDeltaDiff) { + // pg-delta: one migration file per plan unit via the shared writer. A + // single-unit plan (the common case) keeps the exact `_.sql` + // filename; multi-unit plans append the unit name and give each file a + // strictly increasing timestamp so execution + migration-history order stay + // stable. The full set is collision-checked against existing migrations and + // each file is written exclusively so a pre-existing migration is never + // overwritten. Mirrors Go's `WritePgDeltaMigrations` + // (`internal/db/diff/pgdelta_migrations.go`). Empty plans are handled by the + // `diffEmpty` in-sync branch above, so `planFiles` is non-empty here. + const planFiles = diffOutcome.files ?? []; + const writtenUnits = yield* legacyWritePgDeltaMigrations(fs, path, { + workdir: cliConfig.workdir, + baseMillis: nowMillis, + name, + files: planFiles.map((file) => ({ name: file.name, sql: file.sql })), + }).pipe( + Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), + ); + for (const unit of writtenUnits) { + writtenMigrations.push({ path: unit.path, version: unit.version }); + } + } else { + if (!diffEmpty) { + if (seededFromDump) { + // Append the migra diff to the dump-seeded file (Go's `diffRemoteSchema` + // opens the migration file `O_APPEND`, `pull.go:191`). + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(migrationPath, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to open migration file: ${cause.message}`, + }), + ), + ); + yield* file.writeAll(new TextEncoder().encode(out)).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to write migration file: ${cause.message}`, + }), + ), + ); + }), + ); + } else { + yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( + Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), + ); + yield* fs.writeFileString(migrationPath, out).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to write migration file: ${cause.message}`, + }), + ), + ); + } + } + + // Go's `ensureMigrationWritten` (`pull.go:68,263-268`): a dump that produced + // nothing followed by an empty diff leaves the file empty → in sync. + if (seededFromDump && !seedWroteBytes && diffEmpty) { + return yield* Effect.fail( + new LegacyDbPullInSyncError({ message: "No schema changes found" }), ); } + writtenMigrations.push({ path: migrationPath, version: timestamp }); } - // Go's `ensureMigrationWritten` (`pull.go:68,263-268`): a dump that produced - // nothing followed by an empty diff leaves the file empty → in sync. - if (seededFromDump && !seedWroteBytes && diffEmpty) { - return yield* Effect.fail( - new LegacyDbPullInSyncError({ message: "No schema changes found" }), - ); + for (const written of writtenMigrations) { + yield* output.raw(`Schema written to ${legacyBold(written.path)}\n`, "stderr"); } - yield* output.raw(`Schema written to ${legacyBold(migrationPath)}\n`, "stderr"); - // Prompt to update the remote migration history table. Go calls // `PromptYesNo(ctx, "Update remote migration history table?", true)` // (`internal/db/pull/pull.go:73`), which returns the default (`true`) on @@ -739,14 +757,19 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (`console.go:64-82`), and otherwise prompts on a real TTY. const shouldUpdate = yield* legacyPromptYesNo(output, yes, updateHistoryTitle, true); if (shouldUpdate) { - yield* legacyUpdateMigrationHistory(session, fs, path, migrationPath, timestamp); + yield* legacyUpdateMigrationHistory(session, fs, path, writtenMigrations); remoteHistoryUpdated = true; } if (output.format !== "text") { yield* output.success("Schema pulled.", { declarative: false, - schemaWritten: migrationPath, + // `schemaWritten` keeps the first written path for released consumers that + // read the string field; `schemaFiles` lists EVERY written migration path + // in write order (a pg-delta plan writes one file per unit), so machine + // callers see all of them, not just the first. + schemaWritten: writtenMigrations[0]?.path ?? migrationPath, + schemaFiles: writtenMigrations.map((written) => written.path), remoteHistoryUpdated, engine: usePgDeltaDiff ? "pg-delta" : "migra", }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 1e15768888..248c0be283 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; import { + legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, mockLegacyTelemetryStateTracked, @@ -44,6 +45,21 @@ const EXPORT_JSON = JSON.stringify({ files: [{ path: "schemas/public/t.sql", order: 0, statements: 1, sql: "create table t ();" }], }); +// Builds the pg-delta diff envelope printed by `templates/pgdelta.ts`: one file +// per execution-aware plan unit (`{version:1,files:[{order,name,transactionMode,sql}]}`). +const pgDeltaDiffEnvelope = ( + units: ReadonlyArray<{ name: string; sql: string; transactionMode?: string }>, +): string => + JSON.stringify({ + version: 1, + files: units.map((unit, index) => ({ + order: index + 1, + name: unit.name, + transactionMode: unit.transactionMode ?? "transactional", + sql: unit.sql, + })), + }); + interface SetupOpts { readonly format?: OutputFormat; readonly remoteVersions?: ReadonlyArray; @@ -78,6 +94,8 @@ interface SetupOpts { // `--declarative` and `--use-pg-delta` are present, to replay pflag's // last-occurrence-wins ordering; defaults to empty. readonly args?: ReadonlyArray; + // When set, the Nth `writeFileString` fails, exercising cleanup-on-failure. + readonly failWriteOnCall?: number; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -222,7 +240,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); - const layer = Layer.mergeAll( + const baseLayer = Layer.mergeAll( out.layer, telemetry.layer, cache.layer, @@ -250,6 +268,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockRuntimeInfo(), BunServices.layer, ); + // Merged last so its `FileSystem` overrides `BunServices` (last-wins); `Path` + // still resolves from `BunServices`. + const layer = + opts.failWriteOnCall === undefined + ? baseLayer + : Layer.merge(baseLayer, legacyFailWriteStringOnNthCallFsLayer(opts.failWriteOnCall)); return { layer, @@ -303,20 +327,158 @@ describe("legacy db pull", () => { seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", + edgeStdout: pgDeltaDiffEnvelope([ + { + name: "schema_changes", + sql: "-- Migration unit 1: schema_changes\n\ncreate table remote ();", + }, + ]), yes: true, }); return Effect.gen(function* () { yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); const dir = join(tmp.current, "supabase", "migrations"); expect(existsSync(join(dir, `${"20240101000000"}_local.sql`))).toBe(true); - // A new timestamped remote_schema migration was written. + // A single-unit plan keeps the unchanged `_remote_schema.sql` filename. + const written = readdirSync(dir).filter((f) => f.endsWith("_remote_schema.sql")); + expect(written).toHaveLength(1); + expect(readFileSync(join(dir, written[0] ?? ""), "utf8")).toContain( + "create table remote ();", + ); expect(streamText(s.out, "stderr")).toContain("Schema written to"); expect(s.historyUpserts.length).toBe(1); expect(streamText(s.out, "stdout")).toContain("Finished supabase db pull."); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "a pg-delta plan with transaction boundaries writes one ordered migration file per unit", + () => { + // pg-delta plans that cross a transaction boundary (e.g. ALTER TYPE ... ADD + // VALUE then a statement using the new value) come back as several units; each + // is written to its own migration file with a strictly increasing timestamp and + // recorded in the remote history. Mirrors Go's `writePgDeltaMigrations`. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: pgDeltaDiffEnvelope([ + { name: "schema_changes", sql: "-- unit 1\n\nalter type mood add value 'ok';" }, + { name: "after_enum_values", sql: "-- unit 2\n\ninsert into t values ('ok');" }, + { + name: "non_transactional", + transactionMode: "none", + sql: "-- unit 3\n\ncreate index concurrently i on t (c);", + }, + ]), + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); + const dir = join(tmp.current, "supabase", "migrations"); + const written = readdirSync(dir) + .filter((f) => f !== "20240101000000_local.sql") + .sort(); + expect(written).toHaveLength(3); + // Multi-unit plans append the unit name and carry strictly increasing versions. + expect(written[0]).toMatch(/^\d{14}_remote_schema_schema_changes\.sql$/u); + expect(written[1]).toMatch(/^\d{14}_remote_schema_after_enum_values\.sql$/u); + expect(written[2]).toMatch(/^\d{14}_remote_schema_non_transactional\.sql$/u); + const versions = written.map((f) => f.slice(0, 14)); + expect((versions[0] ?? "") < (versions[1] ?? "")).toBe(true); + expect((versions[1] ?? "") < (versions[2] ?? "")).toBe(true); + expect(readFileSync(join(dir, written[2] ?? ""), "utf8")).toContain( + "create index concurrently i on t (c);", + ); + // One "Schema written to" line and one history upsert per unit. + expect(streamText(s.out, "stderr").match(/Schema written to/gu)).toHaveLength(3); + expect(s.historyUpserts.length).toBe(3); + // Go's UpdateMigrationTable prints all versions space-separated. + expect(streamText(s.out, "stderr")).toContain( + `Repaired migration history: [${versions.join(" ")}] => applied`, + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a multi-unit pg-delta pull reports every written migration path in the json payload", + () => { + // The structured payload must list ALL written migration files in write order, + // not just the first (`schemaWritten`). A pg-delta plan writes one file per unit. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + format: "json", + remoteVersions: ["20240101000000"], + edgeStdout: pgDeltaDiffEnvelope([ + { name: "schema_changes", sql: "-- unit 1\n\nalter type mood add value 'ok';" }, + { name: "after_enum_values", sql: "-- unit 2\n\ninsert into t values ('ok');" }, + { + name: "non_transactional", + transactionMode: "none", + sql: "-- unit 3\n\ncreate index concurrently i on t (c);", + }, + ]), + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); + const success = s.out.messages.find((m) => m.type === "success"); + const data = success?.data as + | { schemaWritten?: string; schemaFiles?: Array } + | undefined; + expect(data?.schemaFiles).toHaveLength(3); + // Paths appear in write order, each carrying its unit name. + expect(data?.schemaFiles?.[0]).toMatch(/_remote_schema_schema_changes\.sql$/u); + expect(data?.schemaFiles?.[1]).toMatch(/_remote_schema_after_enum_values\.sql$/u); + expect(data?.schemaFiles?.[2]).toMatch(/_remote_schema_non_transactional\.sql$/u); + // `schemaWritten` stays the first written path (unchanged string contract). + expect(data?.schemaWritten).toBe(data?.schemaFiles?.[0]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("removes already-written unit files when a later pg-delta unit write fails", () => { + // A mid-loop write failure best-effort removes every migration file this + // invocation already wrote, so no partial multi-file pull is left behind. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + failWriteOnCall: 2, + remoteVersions: ["20240101000000"], + edgeStdout: pgDeltaDiffEnvelope([ + { name: "schema_changes", sql: "alter type mood add value 'ok';" }, + { name: "after_enum_values", sql: "insert into t values ('ok');" }, + ]), + yes: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const dir = join(tmp.current, "supabase", "migrations"); + // Only the pre-seeded local migration remains; the first written unit was + // rolled back and the failing second unit never landed. + expect(readdirSync(dir)).toEqual(["20240101000000_local.sql"]); + // No history rows were upserted because the write failed before the prompt. + expect(s.historyUpserts.length).toBe(0); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("a malformed pg-delta diff envelope surfaces a parse error, not 'in sync'", () => { + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "not a valid envelope{", + yes: true, + }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( + Effect.flip, + ); + expect(error.message).toContain("failed to parse pg-delta diff output"); + expect(error.message).not.toContain("No schema changes found"); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("pulls with the default migra engine", () => { seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { @@ -527,8 +689,14 @@ describe("legacy db pull", () => { remoteHistoryUpdated: true, engine: "migra", }); - const data = success?.data as { schemaWritten?: string } | undefined; + const data = success?.data as + | { schemaWritten?: string; schemaFiles?: Array } + | undefined; expect(data?.schemaWritten).toMatch(/_remote_schema\.sql$/u); + // The single-unit case lists exactly one written migration path, and it is the + // same path as the singular `schemaWritten` field. + expect(data?.schemaFiles).toHaveLength(1); + expect(data?.schemaFiles?.[0]).toBe(data?.schemaWritten); }).pipe(Effect.provide(s.layer)); }); @@ -1045,7 +1213,7 @@ describe("legacy db pull", () => { writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", + edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, }); return Effect.gen(function* () { @@ -1158,7 +1326,7 @@ describe("legacy db pull", () => { ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", + edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, resolvedRef: "abcdefghijklmnopqrst", }); @@ -1180,7 +1348,7 @@ describe("legacy db pull", () => { const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeFailFirstWith: "error diffing schema:\nfailed to connect: network is unreachable", - edgeStdout: "create table remote ();\n", + edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, poolerAvailable: true, }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts new file mode 100644 index 0000000000..05b3423db6 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts @@ -0,0 +1,100 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbPullWriteError } from "./pull.errors.ts"; +import { legacyUpdateMigrationHistory, type LegacyPulledMigration } from "./pull.sync.ts"; + +// Records exec statements and successful upserts in one ordered log so the tests +// can assert the transaction envelope (BEGIN / UPSERT / COMMIT / ROLLBACK) around +// the version writes. `failUpsertAt` fails the Nth upsert to simulate a dropped +// connection mid-loop. +function mockSession(opts: { readonly failUpsertAt?: number } = {}) { + const calls: Array = []; + let upsertCount = 0; + const session: LegacyDbSession = { + exec: (sql: string) => Effect.sync(() => void calls.push(sql)), + query: (sql: string) => { + if (/INSERT INTO supabase_migrations/u.test(sql)) { + upsertCount += 1; + if (opts.failUpsertAt === upsertCount) { + return Effect.fail(new LegacyDbExecError({ message: "connection reset by peer" })); + } + calls.push("UPSERT"); + } + return Effect.succeed([] as ReadonlyArray>); + }, + extensionExists: () => Effect.die("extensionExists unused"), + copyToCsv: () => Effect.die("copyToCsv unused"), + queryRaw: () => Effect.die("queryRaw unused"), + }; + return { session, calls }; +} + +function writeMigrations(dir: string): ReadonlyArray { + const migrations: ReadonlyArray = [ + { path: join(dir, "20240101000000_a.sql"), version: "20240101000000" }, + { path: join(dir, "20240101000001_b.sql"), version: "20240101000001" }, + ]; + writeFileSync(migrations[0]!.path, "create table a ();"); + writeFileSync(migrations[1]!.path, "create table b ();"); + return migrations; +} + +describe("legacyUpdateMigrationHistory", () => { + it.effect("wraps the upserts in one BEGIN + N upserts + COMMIT transaction", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = mkdtempSync(join(tmpdir(), "pull-sync-")); + const migrations = writeMigrations(dir); + const out = mockOutput(); + const { session, calls } = mockSession(); + + yield* legacyUpdateMigrationHistory(session, fs, path, migrations).pipe( + Effect.provide(out.layer), + ); + + // The create-table setup runs its own BEGIN/COMMIT first; the upsert + // transaction is the trailing envelope around every version write. + expect(calls).not.toContain("ROLLBACK"); + expect(calls.slice(-4)).toEqual(["BEGIN", "UPSERT", "UPSERT", "COMMIT"]); + // The success line is byte-identical to Go's repair output. + expect(out.stderrText).toContain( + "Repaired migration history: [20240101000000 20240101000001] => applied", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rolls back and surfaces the error when an upsert fails mid-loop", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = mkdtempSync(join(tmpdir(), "pull-sync-")); + const migrations = writeMigrations(dir); + const out = mockOutput(); + const { session, calls } = mockSession({ failUpsertAt: 2 }); + + const error = yield* legacyUpdateMigrationHistory(session, fs, path, migrations).pipe( + Effect.provide(out.layer), + Effect.flip, + ); + + // First upsert applied, second failed → the upsert transaction ends in + // ROLLBACK, never COMMIT (the create-table setup's own COMMIT ran earlier). + expect(calls.slice(-3)).toEqual(["BEGIN", "UPSERT", "ROLLBACK"]); + expect(calls[calls.length - 1]).toBe("ROLLBACK"); + // Error message shape stays byte-identical to the pre-transaction version. + expect(error).toBeInstanceOf(LegacyDbPullWriteError); + expect(error.message).toBe("failed to update migration table: connection reset by peer"); + // No success line when the repair failed. + expect(out.stderrText).not.toContain("Repaired migration history"); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts index 3e251e4c1b..7d00895074 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -10,50 +10,80 @@ import { import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; import { LegacyDbPullWriteError } from "./pull.errors.ts"; +/** A pulled migration file paired with the version to record in the history. */ +export interface LegacyPulledMigration { + readonly path: string; + readonly version: string; +} + /** - * Records the pulled migration as applied in `supabase_migrations.schema_migrations` - * WITHOUT re-executing it (the schema already exists on the remote). Mirrors Go's - * `repair.UpdateMigrationTable(conn, [version], Applied, false, fsys)` + * Records the pulled migration(s) as applied in + * `supabase_migrations.schema_migrations` WITHOUT re-executing them (the schema + * already exists on the remote). Mirrors Go's + * `repair.UpdateMigrationTable(conn, versions, Applied, false, fsys)` * (`internal/migration/repair/repair.go:58`): create the history table, then UPSERT - * the version row with the migration's name + statements. + * each version row with the migration's name + statements. A pg-delta pull whose + * plan crosses a transaction boundary writes several ordered files, so several + * versions are recorded in one pass. */ export const legacyUpdateMigrationHistory = ( session: LegacyDbSession, fs: FileSystem.FileSystem, path: Path.Path, - migrationPath: string, - timestamp: string, + migrations: ReadonlyArray, ) => Effect.gen(function* () { const output = yield* Output; - const match = MIGRATE_FILE_PATTERN.exec(path.basename(migrationPath)); - if (match === null || match[1] !== timestamp) { - // Go resolves the repair file by globbing `_*.sql` against the - // migrations dir and fails with `os.ErrNotExist` when nothing matches - // (`repair.GetMigrationFile`, `internal/migration/repair/repair.go:90-99`). - // The glob is anchored on the GENERATED `timestamp` and `*` never crosses a - // path separator, so a migration name with a separator (`supabase db pull - // dir/...`) writes a nested file the glob can't reach — even when the nested - // basename is itself a valid migration filename (`dir/20250101000000_backfill` - // → basename `20250101000000_backfill.sql`, which DOES match the regex but - // carries the user's nested timestamp, not the generated one). Require the - // basename to both match the pattern AND carry the generated timestamp, - // mirroring Go's anchored glob, rather than trusting `path.basename`. - return yield* Effect.fail( - new LegacyDbPullWriteError({ - message: `glob supabase/migrations/${timestamp}_*.sql: file does not exist`, - }), - ); + // Resolve each file the way Go's `repair.GetMigrationFile` globs + // `_*.sql` against the migrations dir, failing with `os.ErrNotExist` + // when nothing matches (`internal/migration/repair/repair.go:90-99`). The glob + // is anchored on the GENERATED version and `*` never crosses a path separator, + // so a migration name with a separator writes a nested file the glob can't + // reach — require the basename to both match the pattern AND carry the + // generated version rather than trusting `path.basename`. + const resolved: Array<{ version: string; name: string; migrationPath: string }> = []; + for (const migration of migrations) { + const match = MIGRATE_FILE_PATTERN.exec(path.basename(migration.path)); + if (match === null || match[1] !== migration.version) { + return yield* Effect.fail( + new LegacyDbPullWriteError({ + message: `glob supabase/migrations/${migration.version}_*.sql: file does not exist`, + }), + ); + } + resolved.push({ + version: migration.version, + name: match[2] ?? "", + migrationPath: migration.path, + }); } - // Guarded above: match[1] === timestamp, so use the generated timestamp - // directly (avoids re-deriving a `string | undefined` from the regex group). - const version = timestamp; - const name = match[2] ?? ""; yield* Effect.gen(function* () { - const content = yield* fs.readFileString(migrationPath); - const statements = legacySplitAndTrim(content); + // Create the history schema/table first, in its OWN transaction — Go runs + // `CreateMigrationTable` before the upsert batch (`repair.go:59`). Keeping it + // outside the upsert transaction below avoids nesting BEGINs + // (`legacyCreateMigrationTable` issues its own BEGIN/COMMIT). yield* legacyCreateMigrationTable(session); - yield* session.query(UPSERT_MIGRATION_VERSION, [version, name, statements]); + // Record every version in ONE explicit transaction: a mid-loop failure + // (dropped connection, unreadable migration file) must record NONE of them. + // Go queues all upserts in a single `pgx.Batch` (`repair.go:63-83`), which + // Postgres executes as one implicit transaction; without a transaction here + // each UPSERT autocommits, so a failure partway through would leave partial + // remote history that fails the next pull's sync check. + yield* Effect.gen(function* () { + yield* session.exec("BEGIN"); + for (const entry of resolved) { + const content = yield* fs.readFileString(entry.migrationPath); + const statements = legacySplitAndTrim(content); + yield* session.query(UPSERT_MIGRATION_VERSION, [entry.version, entry.name, statements]); + } + yield* session.exec("COMMIT"); + }).pipe( + // Roll back on ANY failure inside the transaction — including a migration + // file read that fails after BEGIN. `Effect.ignore` keeps a ROLLBACK + // failure from masking the original error (`tapError` re-raises the + // original). Mirrors `legacyCreateMigrationTable`'s rollback handling. + Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), + ); }).pipe( Effect.mapError( (cause) => @@ -63,8 +93,9 @@ export const legacyUpdateMigrationHistory = ( ), ); // Match Go's `repair.UpdateMigrationTable(..., repairAll=false, ...)`, which - // prints `Repaired migration history: [] => applied` to stderr - // (`internal/migration/repair/repair.go`). Plain text on stderr, so it does - // not interfere with machine-output payloads on stdout. - yield* output.raw(`Repaired migration history: [${version}] => applied\n`, "stderr"); + // prints `Repaired migration history: [ ...] => applied` to stderr + // (Go's `%v` over the `[]string` of versions, space-separated). Plain text on + // stderr, so it does not interfere with machine-output payloads on stdout. + const versions = resolved.map((entry) => entry.version).join(" "); + yield* output.raw(`Repaired migration history: [${versions}] => applied\n`, "stderr"); }); diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 11337a8955..6b928c8c4b 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -1,59 +1,112 @@ # `supabase db push` +Native TypeScript port of `apps/cli-go/internal/db/push/push.go`. Applies pending +local migrations (and optionally seed data and custom roles) to the local or +linked/remote Postgres database. + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to list migration files to push | -| `/supabase/roles.sql` | SQL | when `--include-roles` is set | -| seed files from config | SQL | when `--include-seed` is set | +| Path | Format | When | +| ------------------------------------- | ---------- | ----------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | +| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | +| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache, Go's `ensureProjectGroupsCached`) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | best-effort, after a successful migration apply, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`); a failure only warns on stderr and never fails the push (Go's `pgcache.TryCacheMigrationsCatalog`) | +| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | same gate as above, when the target requires SSL (`legacyPreparePgDeltaRef`) | + +## Database Mutations + +| Statement | When | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `RESET ALL` + `BEGIN` … migration statements … `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` … `COMMIT` | per pending migration (after confirmation) | +| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | +| `RESET ALL` + `BEGIN` … roles.sql statements … `COMMIT` (no history row) | per `--include-roles` globals file (after confirmation) | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets and migrations are applied | +| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | The native handler connects to Postgres directly. On the `--linked` path the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver). | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ---------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the migrations-catalog cache when `[experimental.pgdelta].enabled` is unset | no (project `.env` or shell) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the cache export | no (project `.env` or shell) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success (including "up to date") | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `ErrMissingLocal` — remote versions absent locally (suggests repair/pull) | +| `1` | `ErrMissingRemote` without `--include-all` (suggests `--include-all`) | +| `1` | user declined a confirmation prompt (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | database connection / migration / seed / roles / vault apply failure | ## Output -### `--output-format text` (Go CLI compatible) +Diagnostics ("Connecting to…", "Applying migration…", "Seeding…", "Updating vault +secrets…", skip/up-to-date notices, dry-run plan, prompts) go to **stderr**. The +two summary lines Go prints to **stdout** — ` is up to date.` and +`Finished supabase db push.` (the command name in Aqua) — go to stdout in text +mode; in machine modes they are suppressed and a structured result is emitted. -Prints applied migration versions to stderr. With `--dry-run`, prints the migrations that would be applied. +### `--output-format text` (Go CLI compatible) -### `--output-format json` +Byte-matches Go: connection status, per-item progress, prompts, and the stdout +summary line, including ANSI color (Aqua command name, Bold file paths). -Not applicable. +### `--output-format json` / `stream-json` -### `--output-format stream-json` +stdout is payload-only. A single `result` object is emitted: -Not applicable. +```json +{ + "upToDate": false, + "dryRun": false, + "migrations": [".sql"], + "seeds": ["supabase/seed.sql"], + "roles": ["supabase/roles.sql"] +} +``` ## Notes -- `--dry-run` prints the migrations that would be applied without applying them. -- `--include-all` includes all migrations not found in remote history table. -- `--include-roles` includes custom roles from the roles file. -- `--include-seed` includes seed data from config. -- `--db-url`, `--linked` (default true), and `--local` are mutually exclusive. +- **Targets**: `--db-url`, `--linked` (default), and `--local` are mutually + exclusive; with no flag the target defaults to linked, matching Go. +- **Prompt order**: custom roles → migrations → seeds; each defaults to "yes" and + declining returns `context canceled`. +- **`--dry-run`** prints the plan (roles / migrations / seeds) and applies nothing. +- **`[db.migrations].enabled = false`** / **`[db.seed].enabled = false`** print a + skip notice naming the project ref (empty for local/db-url). +- **Vault**: non-empty, non-`env()` `[db.vault]` values are synced after config + load, including decrypted `encrypted:` values. +- **Migrations catalog cache**: ported (Go's best-effort `pgcache.TryCacheMigrationsCatalog`). + After a successful migration apply, when pg-delta is enabled, exports the target's + pg-delta catalog via the edge-runtime stack and writes it under + `supabase/.temp/pgdelta/`, pruning older snapshots for the same prefix (retains 2). + A failure only warns on stderr (`Warning: failed to cache migrations catalog: …`) + and never fails the push, matching Go exactly. Reuses `legacyExportCatalogPgDelta` + (the same pg-delta export path `db pull`/`db diff` use, which always mounts the + project root at `/workspace`) rather than a second copy, so the ENOENT bug fixed in + Go's `pgcache/cache.go` (supabase/cli#5921) has no TS equivalent. diff --git a/apps/cli/src/legacy/commands/db/push/push.command.ts b/apps/cli/src/legacy/commands/db/push/push.command.ts index 2d6d8d17e9..8c936315ca 100644 --- a/apps/cli/src/legacy/commands/db/push/push.command.ts +++ b/apps/cli/src/legacy/commands/db/push/push.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbPush } from "./push.handler.ts"; +import { legacyDbPushRuntimeLayer } from "./push.layers.ts"; const config = { includeAll: Flag.boolean("include-all").pipe( @@ -37,5 +41,24 @@ export type LegacyDbPushFlags = CliCommand.Command.Config.Infer; export const legacyDbPushCommand = Command.make("push", config).pipe( Command.withDescription("Push new migrations to the remote database."), Command.withShortDescription("Push new migrations to the remote database"), - Command.withHandler((flags) => legacyDbPush(flags)), + Command.withHandler((flags) => + legacyDbPush(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "include-all": flags.includeAll, + "include-roles": flags.includeRoles, + "include-seed": flags.includeSeed, + "dry-run": flags.dryRun, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + // `password` is a credential — always reaches telemetry as ``. + password: flags.password, + }, + aliases: { p: "password" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbPushRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/push/push.errors.ts b/apps/cli/src/legacy/commands/db/push/push.errors.ts new file mode 100644 index 0000000000..3849d55add --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.errors.ts @@ -0,0 +1,56 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte + * (`apps/cli-go/cmd/db.go:526`). + */ +export class LegacyDbPushTargetFlagsError extends Data.TaggedError("LegacyDbPushTargetFlagsError")<{ + readonly message: string; +}> {} + +/** + * Remote migration versions are missing from the local directory. Byte-matches + * Go's `migration.ErrMissingLocal` (`pkg/migration/apply.go:16`); the + * `migration repair` / `db pull` suggestion is attached (Go's `CmdSuggestion`). + */ +export class LegacyDbPushMissingLocalError extends Data.TaggedError( + "LegacyDbPushMissingLocalError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * Local migration files are ordered before the remote head and `--include-all` + * was not passed. Byte-matches Go's `migration.ErrMissingRemote` + * (`pkg/migration/apply.go:15`); the `--include-all` suggestion is attached. + */ +export class LegacyDbPushMissingRemoteError extends Data.TaggedError( + "LegacyDbPushMissingRemoteError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * The user declined a confirmation prompt. Go returns `errors.New(context.Canceled)` + * (`internal/db/push/push.go:80,91,110`), rendered as `context canceled`. + */ +export class LegacyDbPushCancelledError extends Data.TaggedError("LegacyDbPushCancelledError")<{ + readonly message: string; +}> {} + +/** Locating `supabase/roles.sql` failed (Go's `failed to find custom roles: %w`). */ +export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesError")<{ + readonly message: string; +}> {} + +/** + * A migration / seed / globals / vault statement failed while applying. Carries + * the underlying Postgres error (with Go's `At statement: ` context for + * migrations) so stderr matches Go's propagated error. + */ +export class LegacyDbPushApplyError extends Data.TaggedError("LegacyDbPushApplyError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index fddb270174..47ea574459 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -1,17 +1,371 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Clock, Effect, FileSystem, Option, Path } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + legacyApplyProjectEnv, + legacyCheckDbToml, + legacyLoadProjectEnv, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyApplyMigrations, + legacySeedGlobals, +} from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { redactLegacyConnectionString } from "../../../shared/legacy-db-config.parse.ts"; +import { legacyParseBoolEnv } from "../shared/legacy-diff-engine.ts"; +import { + legacyListLocalMigrations, + legacyTryCacheMigrationsCatalog, +} from "../shared/legacy-pgdelta.cache.ts"; +import { type LegacyPgDeltaContext } from "../shared/legacy-pgdelta.ts"; +import { + LEGACY_ERR_MISSING_LOCAL, + LEGACY_ERR_MISSING_REMOTE, + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "../shared/legacy-migration-pending.ts"; +import { + type LegacySeedFile, + legacyGetPendingSeeds, + legacySeedData, +} from "../shared/legacy-seed-ops.ts"; +import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; +// Listing the remote `schema_migrations` history (with the 42P01 → empty rule) +// lives in the shared migration-history module (Go's `migration.ListRemoteMigrations`). +import { legacyListRemoteMigrations } from "../../../shared/legacy-migration-history.ts"; import type { LegacyDbPushFlags } from "./push.command.ts"; +import { + LegacyDbPushApplyError, + LegacyDbPushCancelledError, + LegacyDbPushMissingLocalError, + LegacyDbPushMissingRemoteError, + LegacyDbPushRolesError, + LegacyDbPushTargetFlagsError, +} from "./push.errors.ts"; + +const CUSTOM_ROLES_PATH = "supabase/roles.sql"; + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +/** Go's `confirmPushAll` (`internal/db/push/push.go:123-129`) — bold filenames. */ +const confirmPushAll = (filenames: ReadonlyArray): string => + filenames.map((name) => ` • ${legacyBold(name)}\n`).join(""); + +/** Go's `confirmSeedAll` (`internal/db/push/push.go:131-140`) — bold paths, hash notice. */ +const confirmSeedAll = (seeds: ReadonlyArray): string => + seeds + .map((seed) => ` • ${legacyBold(seed.dirty ? `${seed.path} (hash update)` : seed.path)}\n`) + .join(""); + +const applyError = (message: string) => new LegacyDbPushApplyError({ message }); +/** + * `supabase db push` — apply pending local migrations (and optionally seed data + * and custom roles) to the local or linked/remote database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/push/push.go`. + */ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: LegacyDbPushFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "push"]; - if (flags.includeAll) args.push("--include-all"); - if (flags.includeRoles) args.push("--include-roles"); - if (flags.includeSeed) args.push("--include-seed"); - if (flags.dryRun) args.push("--dry-run"); - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - yield* proxy.exec(args); + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + + const workdir = cliConfig.workdir; + // Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-`.env` + // key) before `PromptYesNo` reads `viper.GetBool("YES")`, so a `SUPABASE_YES` set only in + // `supabase/.env` auto-confirms. Resolve `yes` with that project env, as `db pull` does. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + yield* legacyApplyProjectEnv(projectEnv); + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"), keyed off the + // explicitly-set flags (cobra's `Changed`), not the `--linked` default value. + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbPushTargetFlagsError({ + 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`, + }), + ); + } + // Go's push defaults `--linked` to true, so no target flag → linked. + const connType = target.connType ?? "linked"; + + // The linked path resolves the project ref before loading config so a matching + // `[remotes.]` block merges (Go's ParseDatabaseConfig → LoadConfig). For + // `--local` / `--db-url`, Go leaves `flags.ProjectRef` empty. + let projectRef = ""; + if (connType === "linked") { + const refResolver = yield* LegacyProjectRefResolver; + projectRef = yield* refResolver.loadProjectRef(Option.none()); + linkedRefForCache = projectRef; + } + + // Single Go-parity config load (`flags.LoadConfig` → `config.Load` + `Validate`): + // decodes the whole config with Go's env-expansion + `strconv.ParseBool` weak typing + // (so `enabled = "env(SEED_ENABLED)"` etc. load like Go), applies `SUPABASE_*` + // AutomaticEnv overrides, merges a matching `[remotes.]` block, and decrypts every + // `encrypted:` secret with the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — + // aborting here (before connecting or writing) on any undecryptable/invalid config. + const toml = yield* legacyCheckDbToml( + fs, + path, + workdir, + projectRef !== "" ? projectRef : undefined, + ); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); + } + const vaultSecrets = toml.vault; + + if (flags.dryRun) { + yield* output.raw("DRY RUN: migrations will *not* be pushed to the database.\n", "stderr"); + } + + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + password: flags.password, + }); + const databaseName = cfg.isLocal ? "local database" : "remote database"; + const statusTarget = cfg.isLocal ? "Local database" : "Remote database"; + + yield* Effect.scoped( + Effect.gen(function* () { + yield* output.raw( + `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + + // --- Collect pending migrations --- + let pending: ReadonlyArray = []; + if (!toml.migrationsEnabled) { + yield* output.raw( + `Skipping migrations because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + const migrationsDir = path.join(workdir, "supabase", "migrations"); + const remote = yield* legacyListRemoteMigrations(session); + const local = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const result = legacyFindPendingMigrations(local, remote); + if (result.kind === "missing-local") { + return yield* Effect.fail( + new LegacyDbPushMissingLocalError({ + message: LEGACY_ERR_MISSING_LOCAL, + suggestion: legacySuggestRevertHistory(result.versions), + }), + ); + } + if (result.kind === "missing-remote") { + if (!flags.includeAll) { + // Go's suggestIgnoreFlag lists the workdir-relative paths. + const relPaths = result.paths.map((p) => toSlash(path.relative(workdir, p))); + return yield* Effect.fail( + new LegacyDbPushMissingRemoteError({ + message: LEGACY_ERR_MISSING_REMOTE, + suggestion: legacySuggestIgnoreFlag(relPaths), + }), + ); + } + pending = legacyIncludeAllPending(local, remote.length, result.paths); + } else { + pending = result.pending; + } + } + + // --- Collect pending seeds --- + let seeds: ReadonlyArray = []; + if (flags.includeSeed) { + if (!toml.seed.enabled) { + yield* output.raw( + `Skipping seed because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + seeds = yield* legacyGetPendingSeeds(session, fs, path, toml.seed.sqlPaths, workdir); + } + } + + // --- Collect custom roles --- + const globals: Array = []; + if (flags.includeRoles) { + const exists = yield* fs.exists(path.join(workdir, CUSTOM_ROLES_PATH)).pipe( + Effect.mapError( + (cause) => + new LegacyDbPushRolesError({ + message: `failed to find custom roles: ${cause.message}`, + }), + ), + ); + if (exists) globals.push(CUSTOM_ROLES_PATH); + } + + // --- Nothing to push --- + if (pending.length === 0 && seeds.length === 0 && globals.length === 0) { + if (output.format === "text") { + yield* output.raw(`${statusTarget} is up to date.\n`); + } else { + yield* output.success(`${statusTarget} is up to date.`, { + upToDate: true, + dryRun: flags.dryRun, + migrations: [], + seeds: [], + roles: [], + }); + } + return; + } + + if (flags.dryRun) { + if (globals.length > 0) { + yield* output.raw( + `Would create custom roles ${legacyBold(globals[0]!)}...\n`, + "stderr", + ); + } + if (pending.length > 0) { + yield* output.raw("Would push these migrations:\n", "stderr"); + yield* output.raw(confirmPushAll(pending.map((p) => path.basename(p))), "stderr"); + } + if (seeds.length > 0) { + yield* output.raw("Would seed these files:\n", "stderr"); + yield* output.raw(confirmSeedAll(seeds), "stderr"); + } + } else { + // --- Custom roles --- + if (globals.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + "Do you want to create custom roles in the database cluster?", + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacySeedGlobals( + session, + fs, + path, + globals.map((g) => path.join(workdir, g)), + applyError, + ); + } + + // --- Migrations --- + if (pending.length > 0) { + const ok = yield* legacyPromptYesNo( + 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* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacyUpsertVaultSecrets(session, vaultSecrets); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + const cacheEnabled = + toml.pgDelta.enabled || + legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")); + const pgDeltaCtx: LegacyPgDeltaContext = { + projectId: Option.getOrElse(cliConfig.projectId, () => ""), + cwd: workdir, + npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), + denoVersion: toml.denoVersion, + }; + yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { + enabled: cacheEnabled, + targetUrl: legacyToPostgresURL(cfg.conn), + conn: cfg.conn, + isLocal: cfg.isLocal, + migrationsDir: path.join(workdir, "supabase", "migrations"), + nowMillis: yield* Clock.currentTimeMillis, + }).pipe( + Effect.catch((error) => + output.raw( + `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, + "stderr", + ), + ), + ); + } else { + yield* output.raw("Schema migrations are up to date.\n", "stderr"); + } + + // --- Seeds --- + if (seeds.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + `Do you want to seed the ${databaseName} with these files?\n${confirmSeedAll(seeds)}`, + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } else if (flags.includeSeed) { + yield* output.raw("Seed files are up to date.\n", "stderr"); + } + } + + if (output.format === "text") { + yield* output.raw(`Finished ${legacyAqua("supabase db push")}.\n`); + } else { + yield* output.success("Finished supabase db push.", { + upToDate: false, + dryRun: flags.dryRun, + migrations: pending.map((p) => path.basename(p)), + seeds: seeds.map((s) => s.path), + roles: globals, + }); + } + }), + ); + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + Effect.scoped, + ); }); diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts new file mode 100644 index 0000000000..8ebd64c289 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -0,0 +1,976 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; +import { + LegacyEdgeRuntimeScript, + type LegacyEdgeRuntimeRunOpts, +} from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { legacyDbPush } from "./push.handler.ts"; +import type { LegacyDbPushFlags } from "./push.command.ts"; + +const LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; +const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; + +const DEFAULT_FLAGS: LegacyDbPushFlags = { + includeAll: false, + includeRoles: false, + includeSeed: false, + dryRun: false, + dbUrl: Option.none(), + linked: false, + local: true, + password: Option.none(), +}; + +function mockResolver(opts: { isLocal?: boolean } = {}) { + return Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + Effect.succeed({ + conn: LOCAL_CONN, + isLocal: opts.isLocal ?? true, + } satisfies LegacyResolvedDbConfig), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); +} + +function mockConnection(opts: { + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; + failExec?: string; +}) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string): Effect.Effect => + Effect.suspend((): Effect.Effect => { + execs.push(sql); + if (opts.failExec !== undefined && sql === opts.failExec) { + return Effect.fail( + new LegacyDbExecError({ message: "ERROR: boom (SQLSTATE 42601)" }), + ); + } + return Effect.void; + }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === LIST_MIGRATIONS) { + return Effect.succeed( + (opts.remoteMigrations ?? []).map((version) => ({ version })), + ); + } + if (sql === SELECT_SEEDS) { + if (opts.noSeedTable === true) { + return Effect.fail( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.seed_files" does not exist', + code: "42P01", + }), + ); + } + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === READ_VAULT) { + return Effect.succeed(opts.vaultRows ?? []); + } + return Effect.succeed([]); + }, + ), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + yes?: boolean; + isLocal?: boolean; + projectRef?: string; + linkedFails?: boolean; + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; + failExec?: string; + catalogStdout?: string; + catalogExportFailWith?: string; + noProjectId?: boolean; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + + const edgeRunCalls: Array = []; + const registryEnvAtRunTime: Array = []; + const edge = Layer.succeed(LegacyEdgeRuntimeScript, { + run: (runOpts: LegacyEdgeRuntimeRunOpts) => { + edgeRunCalls.push(runOpts); + registryEnvAtRunTime.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); + if (opts.catalogExportFailWith !== undefined) { + return Effect.fail( + new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), + ); + } + return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); + }, + }); + const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }); + const projectRefLayer = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? LEGACY_VALID_REF)), + loadProjectRef: () => + opts.linkedFails === true + ? Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ) + : Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + }); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + mockResolver({ isLocal: opts.isLocal ?? true }), + mockLegacyCliConfig({ + workdir, + ...(opts.noProjectId === true ? { projectId: Option.none() } : {}), + }), + BunServices.layer, + // Prompts (migration/seed confirmation) are answered through mockOutput's + // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is + // only referenced by legacyPromptYesNo's non-TTY branch (unreached here). + mockTty({ stdinIsTty: true }), + mockStdin(true), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "push", "--local"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + projectRefLayer, + telemetry.layer, + linkedCache.layer, + edge, + sslProbe, + ); + return { layer, out, conn, telemetry, linkedCache, edgeRunCalls, registryEnvAtRunTime }; +} + +const MIGRATION_DIR = "supabase/migrations"; +const migrationFile = (version: string, body = "create table t ();") => ({ + [`${MIGRATION_DIR}/${version}_test.sql`]: body, +}); + +describe("legacy db push", () => { + const tmp = useLegacyTempWorkdir("supabase-db-push-"); + + it.live("reports up to date when nothing is pending (text)", () => { + const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + // No migration was applied. + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("emits a json result for an up-to-date run", () => { + const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', format: "json" }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["upToDate"]).toBe(true); + expect(success?.data?.["migrations"]).toEqual([]); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--local", "--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("applies a pending migration after confirmation", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + // "supabase db push" is wrapped in Aqua (cyan) on stdout, matching Go. + expect(out.stdoutText).toContain("Finished"); + expect(out.stdoutText).toContain("supabase db push"); + // The migration body + history insert ran inside a transaction. + expect(conn.execs).toContain("BEGIN"); + expect(conn.execs).toContain("COMMIT"); + expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe( + true, + ); + }); + }); + + it.live("does not attempt to cache the migrations catalog when pg-delta is disabled", () => { + const { layer, out, edgeRunCalls } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(edgeRunCalls).toHaveLength(0); + expect(out.stderrText).not.toContain("failed to cache migrations catalog"); + expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); + }); + }); + + it.live("caches the migrations catalog when project .env enables pg-delta", () => { + const { layer, out, edgeRunCalls } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n", + }, + confirm: [true], + catalogStdout: '{"snapshot":"ok"}', + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("failed to cache migrations catalog"); + expect(edgeRunCalls).toHaveLength(1); + const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); + const catalogFiles = readdirSync(tempDir).filter((name) => + name.startsWith("catalog-local-migrations-"), + ); + expect(catalogFiles).toHaveLength(1); + }); + }); + + it.live("caches the migrations catalog after a successful push when pg-delta is enabled", () => { + const { layer, out, edgeRunCalls } = setup(tmp.current, { + toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', + files: migrationFile("20240101000000"), + confirm: [true], + catalogStdout: '{"snapshot":"ok"}', + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("failed to cache migrations catalog"); + expect(edgeRunCalls).toHaveLength(1); + const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); + const catalogFiles = readdirSync(tempDir).filter((name) => + name.startsWith("catalog-local-migrations-"), + ); + expect(catalogFiles).toHaveLength(1); + expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); + }); + }); + + it.live("caches the migrations catalog with an empty projectId when none is resolved", () => { + const { layer, out, edgeRunCalls } = setup(tmp.current, { + toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', + files: migrationFile("20240101000000"), + confirm: [true], + catalogStdout: '{"snapshot":"ok"}', + noProjectId: true, + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("failed to cache migrations catalog"); + expect(edgeRunCalls).toHaveLength(1); + }); + }); + + it.live("warns without failing the push when the catalog export fails", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', + files: migrationFile("20240101000000"), + confirm: [true], + catalogExportFailWith: "edge-runtime script produced no output", + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain( + "Warning: failed to cache migrations catalog: edge-runtime script produced no output", + ); + expect(out.stdoutText).toContain("Finished"); + }); + }); + + it.live( + "resolves the pg-delta cache export image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", + () => { + const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + const { layer, registryEnvAtRunTime } = setup(tmp.current, { + toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + }, + confirm: [true], + catalogStdout: '{"snapshot":"ok"}', + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(registryEnvAtRunTime).toEqual(["my-mirror.example.com"]); + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; + }), + ), + ); + }, + ); + + it.live("returns context canceled when the migration prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("context canceled"); + } + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("prints the plan without applying in dry-run mode", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("DRY RUN: migrations will *not* be pushed to the database."); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("20240101000000_test.sql"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("fails with a repair suggestion when remote has versions missing locally", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + remoteMigrations: ["20240101000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Remote migration versions not found in local migrations directory.", + ); + expect(JSON.stringify(exit.cause)).toContain("migration repair --status reverted"); + } + expect(out).toBeDefined(); + }); + }); + + it.live("fails with an --include-all suggestion for out-of-order local migrations", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + // 0101 is local-only and ordered before the already-applied remote 0202. + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--include-all"); + } + }); + }); + + it.live("pushes out-of-order migrations with --include-all", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeAll: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("skips migrations when disabled in config and reports up to date", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping migrations because it is disabled in config.toml for project:", + ); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("seeds a new file with --include-seed", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect( + conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), + ).toBe(true); + }); + }); + + it.live("expands a directory in [db.seed].sql_paths to its sorted .sql children", () => { + // Go's `GetPendingSeeds` (`Glob.SQLFiles`) walks a matched directory and seeds its + // regular `.sql` files recursively; non-.sql files are skipped. Without dir expansion + // the directory path reached `readFileString()` and failed. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["seeds"]\n', + files: { + "supabase/seeds/a.sql": "insert into t values (1);", + "supabase/seeds/nested/b.sql": "insert into t values (2);", + "supabase/seeds/notes.txt": "not a seed", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seeds/a.sql..."); + expect(out.stderrText).toContain("Seeding data from supabase/seeds/nested/b.sql..."); + expect(out.stderrText).not.toContain("notes.txt"); + }); + }); + + it.live("reports seed files up to date when hash matches remote", () => { + // sha256 of the seed body must match the remote hash to be skipped. + const body = "insert into t values (1);"; + const hash = createHash("sha256").update(body).digest("hex"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": body }, + remoteSeeds: { "supabase/seed.sql": hash }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("hashes a non-UTF-8 seed file by its raw bytes (Go's io.Copy parity)", () => { + // Go's `NewSeedFile` hashes the raw stream; a UTF-8 string decode would replace the + // invalid bytes and change the hash. Write invalid UTF-8 and pre-seed the remote with + // the RAW-byte sha256 — the push must treat it as already-applied (byte hash matches), + // not re-run it. A string-decoded hash here would differ and mark the seed dirty. + const raw = Buffer.from([0x2d, 0x2d, 0x20, 0xff, 0xfe, 0x00, 0x01, 0x0a]); + const rawHash = createHash("sha256").update(raw).digest("hex"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + remoteSeeds: { "supabase/seed.sql": rawHash }, + }); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "seed.sql"), raw); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("skips seeding when disabled in config", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping seed because it is disabled in config.toml for project:", + ); + }); + }); + + it.live("creates custom roles with --include-roles", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding globals from roles.sql..."); + }); + }); + + it.live("--include-roles without a roles.sql pushes migrations and skips globals", () => { + // Go's push only globs supabase/roles.sql when it exists; an absent file is + // silently skipped (no error, no "Seeding globals" line) and the rest pushes. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("Seeding globals"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("emits the seeded file paths in the json success payload", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["upToDate"]).toBe(false); + expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); + expect(success?.data?.["seeds"]).toEqual(["supabase/seed.sql"]); + }); + }); + + it.live("reports schema migrations up to date when only roles are pushed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Schema migrations are up to date."); + }); + }); + + it.live("returns context canceled when the roles prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("returns context canceled when the seed prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("re-hashes a dirty seed without re-running its statements", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + // Remote hash differs → dirty. + remoteSeeds: { "supabase/seed.sql": "stalehash" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating seed hash to supabase/seed.sql..."); + // Dirty seed only upserts the hash; the body statement is not executed. + expect(conn.execs).not.toContain("insert into t values (1);"); + }); + }); + + it.live("treats every seed as pending when the seed_files table is absent", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + noSeedTable: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + }); + }); + + it.live("warns and reports up to date when no seed files match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("WARN: no files matched pattern: supabase/missing.sql"); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("reports seed files up to date when migrations push but no seeds match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seed files are up to date."); + }); + }); + + it.live("upserts vault secrets (update existing, create new) before migrating", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', + files: migrationFile("20240101000000"), + // `existing` already present remotely → update; `fresh` → create. + vaultRows: [{ id: "id-1", name: "existing" }], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating vault secrets..."); + const sqls = conn.queries.map((q) => q.sql); + expect(sqls).toContain("SELECT vault.update_secret($1, $2)"); + expect(sqls).toContain("SELECT vault.create_secret($1, $2)"); + }); + }); + + it.live("decrypts an encrypted vault secret keyed by the project .env (not process.env)", () => { + // Regression: the old point-of-use vault decryption keyed only on `process.env`, so a + // `DOTENV_PRIVATE_KEY` present only in the project `.env` failed to decrypt. Go's config + // load merges the project `.env` into the key set (`legacyCheckDbToml`), so it resolves. + const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; + const ENCRYPTED = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + const { layer, out, conn } = setup(tmp.current, { + toml: `project_id = "test"\n\n[db.vault]\nmy_secret = "${ENCRYPTED}"\n`, + files: { + ...migrationFile("20240101000000"), + "supabase/.env": `DOTENV_PRIVATE_KEY=${PRIVATE_KEY}\n`, + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating vault secrets..."); + // The decrypted plaintext ("value") is written, proving the project-.env key was used. + const create = conn.queries.find((q) => q.sql === "SELECT vault.create_secret($1, $2)"); + expect(create?.params).toEqual(["value", "my_secret"]); + }); + }); + + it.live("defaults to the linked target when no target flag is set", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(out.stdoutText).toBe("Remote database is up to date.\n"); + }); + }); + + it.live("surfaces an apply error with statement context", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000", "BOOM;"), + failExec: "BOOM", + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("At statement: 0"); + } + }); + }); + + it.live("dry-run lists roles, migrations and seeds without applying", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/roles.sql": "create role app;", + "supabase/seed.sql": "insert into t values (1);", + }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ + ...DEFAULT_FLAGS, + dryRun: true, + includeRoles: true, + includeSeed: true, + }).pipe(Effect.provide(layer)); + // The roles path is wrapped in Bold (ANSI), matching Go's utils.Bold. + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).toContain("roles.sql"); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("Would seed these files:"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("dry-run with only custom roles lists them without a migration section", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true, includeRoles: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).not.toContain("Would push these migrations:"); + }); + }); + + it.live("uses embedded defaults when no config file is present", () => { + const { layer, out } = setup(tmp.current, { + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + // No config.toml written → loadProjectConfig returns null → default config + // (migrations enabled), and the vault document is absent. + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("auto-confirms pending migrations via SUPABASE_YES set only in the project .env", () => { + // Go's loadNestedEnv sets project-.env keys before PromptYesNo reads viper YES, so a + // `SUPABASE_YES` in supabase/.env auto-confirms without any interactive answer. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), "supabase/.env": "SUPABASE_YES=true\n" }, + // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("fails when config.toml cannot be parsed", () => { + const { layer } = setup(tmp.current, { toml: "this is = = not [[[ valid toml" }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed + // config aborts with Go's `failed to load config` message (the reader path), same as + // the other db commands (diff/dump/pull/migration). + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + }); + }); + + it.live("loads a Go-style env() boolean in config (no ProjectConfigParseError)", () => { + // Regression for the strict @supabase/config loader rejecting `enabled = "env(VAR)"`: + // Go decodes it via env-expansion + strconv.ParseBool, so the config must load and the + // migration proceed. Previously native push aborted before the Go-compatible parse ran. + const previous = process.env["SEED_ENABLED"]; + process.env["SEED_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SEED_ENABLED"]; + else process.env["SEED_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("a matched remote block's migrations.enabled beats the shell env override", () => { + // Go merges a matched [remotes.] block at viper's override tier (`v.Set`), which + // sits ABOVE AutomaticEnv — so `[remotes.preview.db.migrations] enabled = false` wins + // over `SUPABASE_DB_MIGRATIONS_ENABLED=true` and the push skips migrations. (Before the + // config-reader convergence, push resolved this gate env-first and wrongly applied.) + const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; + process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n\n[remotes.preview.db.migrations]\nenabled = false\n`, + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Skipping migrations because it is disabled"); + expect(out.stderrText).not.toContain("Applying migration 20240101000000"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; + else process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("announces a matching [remotes.*] override on the linked path", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("pushes to the linked project and caches the project ref (json)", () => { + const { layer, out, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + format: "json", + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/push/push.layers.ts b/apps/cli/src/legacy/commands/db/push/push.layers.ts new file mode 100644 index 0000000000..cf99bc494c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.layers.ts @@ -0,0 +1,89 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; +import { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtime-script.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; + +/** + * Runtime layer for `supabase db push`. Same shape as `db lint`: it spans local + * (`--local` / `--db-url`) and linked DB access, so it composes the Postgres + * connection, the db-config resolver, project-ref resolution, and the + * linked-project cache (Go's PersistentPostRun `ensureProjectGroupsCached`). + * + * Like `db lint`, it deliberately uses the **lazy** `legacyPlatformApiFactoryLayer` + * (not the eager management-API runtime) so the auth-free `--local` path never + * resolves an access token at layer-build time. `legacyCliConfigLayer` is provided + * to each consumer that needs it (legacy CLAUDE.md item 5); the single + * `legacyIdentityStitchLayer` reference is shared so the factory, the cache, and + * the db-config resolver share one `stitchAttempted` guard. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(cliConfig), +); + +export const legacyDbPushRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + legacyDockerRunLayer, + edgeRuntime, + legacyPgDeltaSslProbeLayer, + cliConfig, + httpClient, + credentials, + projectRef, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + // `legacyPromptYesNo`'s non-TTY branch reads the piped answer via `Stdin` (Go's + // `console.ReadLine`); without it a CI/piped `db push` that reaches a confirmation + // prompt fails with a missing-service defect instead of honoring `y`/`n` or the default. + stdinLayer, + commandRuntimeLayer(["db", "push"]), +); diff --git a/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md index 2b2e6ad3d2..88785a5530 100644 --- a/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md @@ -37,7 +37,10 @@ ### `--output-format text` (Go CLI compatible) -Prints `Finished supabase db pull.` on success. +Prints `Schema written to ` to stderr on success (from the shared +`pull.Run`, `internal/db/pull/pull.go:72`); no stdout confirmation message is +printed. The `Finished supabase db pull.` PostRun message belongs only to +`db pull` (`cmd/db.go:198-200`), not `db remote commit`. ### `--output-format json` diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index d1291e27a4..0934154974 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -1,60 +1,148 @@ # `supabase db reset` +Native TypeScript port of `apps/cli-go/internal/db/reset/reset.go`. Reinitialises a +database from local migrations (plus seed). The **remote** path (`--linked`, or a +remote `--db-url`) is native: drop all user schemas, upsert vault secrets, then +re-apply migrations and seed. The **local** path (`--local`/default, or a `--db-url` +pointing at the local stack) is also native: TS orchestrates the running check, +messages, bucket seeding, and git-branch line, while the container-recreate +primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche +**`--experimental`** remote schema-files path still delegates to the Go binary. + ## Files Read -| Path | Format | When | -| --------------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to load migration files | -| seed files from config or `--sql-paths` | SQL | unless `--no-seed` is set | +| Path | Format | When | +| ------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | +| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | +| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | +| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | +| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | --------------------------------- | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | + +On the local path the Go seam additionally recreates the `supabase_db_` +container/volume and applies the initial schema (`SetupLocalDatabase`); the +`--experimental` remote path produces whatever the delegated Go binary writes. + +## Subprocesses + +| Command | When | Purpose | +| --------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode recreate [--version ] [--no-seed]` | local path | recreate container + init schema + migrate + seed + restart services | +| `supabase-go db __db-bootstrap --mode await-storage` | local path | storage health gate before bucket seeding (`ready` / `absent`) | +| `supabase-go db reset --linked\|--db-url … [--no-seed]` | `--experimental` remote, no version | the un-ported experimental schema-files apply path (telemetry disabled) | + +The seam subprocesses run with `SUPABASE_TELEMETRY_DISABLED=1`, stderr inherited; +`--network-id` / a flag-selected `--profile` are forwarded. + +## Database Mutations + +### Remote path (native, in TS) + +| Statement | When | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | +| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | +| migration statements + `schema_migrations` history insert (per file, transactional) | when `[db.migrations].enabled`, for migrations `≤ --version` | +| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | + +### Local path (inside the Go seam) + +The recreate seam drops & recreates the `postgres`/`_supabase` databases (PG≤14) or +removes & recreates the db container/volume (PG15), applies the initial schema + +roles, then runs `MigrateAndSeed` (migrations `≤ --version`, seed unless `--no-seed`) +and restarts the storage/auth/realtime/pooler containers. Bucket objects are then +seeded over the Storage gateway (reusing the `seed buckets` local path). ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | routes the experimental schema-files path to Go | no (also `--experimental`) | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `--version` + `--last` together (`[last version]`) | +| `1` | `--version` not an integer (`invalid version number`) | +| `1` | `--version` has no matching migration file | +| `1` | local: database not running (`supabase start is not running.`) | +| `1` | user declined the reset confirmation (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | drop / migrate / seed / vault apply failure, or connection error | +| child's exact code\* | local: container recreate / storage health-gate failure (seam), or `--experimental`/`--linked` delegate (proxy) child exit | + +\* The `db __db-bootstrap` seam and the `--experimental` remote delegate both +propagate the spawned `supabase-go` child's real exit code (e.g. `130` after a +Ctrl-C mid-recreate) instead of collapsing every failure to `1` — in every +`--output-format` (CLI-1879). ## Output +The remote path prints `Resetting remote database…` to **stderr**, then the +drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). Go +connects with `io.Discard`, so there is **no** `Connecting to … database…` line and +**no** `Finished …` line on the remote path. + +The local path prints `Resetting local database…` to **stderr**, then the seam's +`Recreating database...` / `Restarting containers...` progress, and finally +`Finished supabase db reset on branch .` (`supabase db reset` and `` +in Aqua). + ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as migrations are applied. +Byte-matches Go's stderr progress for both the remote and local paths. The +`--experimental` remote path passes the delegated Go binary's output through +unchanged. -### `--output-format json` +### `--output-format json` / `stream-json` -Not applicable. +stdout is payload-only; a `result` object is emitted: -### `--output-format stream-json` +```json +{ "target": "remote" | "local", "version": "" } +``` -Not applicable. +In machine modes the remote confirmation prompt is non-interactive and takes its +default (`false`), so a remote reset is declined unless `--yes` is set. The local +path has no confirmation prompt. ## Notes -- `--no-seed` skips running the seed script after reset. -- `--sql-paths` overrides `[db.seed].sql_paths` for one reset; repeat it to seed multiple files or glob patterns. -- `--sql-paths` force-enables seeding for that reset even when `[db.seed].enabled = false`. -- With `--linked` or `--db-url`, `--sql-paths` seeds the selected remote database after migrations. -- `--version` resets up to the specified migration version. -- `--last` resets up to the last n migration versions; mutually exclusive with `--version`. +- **Target/local split** follows Go's `IsLocalDatabase(resolved config)`, not the + flag name: a `--db-url` pointing at the local stack is treated as a local reset. +- `--no-seed` forces seeding off (Go sets `Config.Db.Seed.Enabled = false`); on the + local path it is forwarded to the recreate seam so `MigrateAndSeed` skips the seed. +- `--sql-paths` overrides `[db.seed].sql_paths` for one reset and force-enables seeding + even when `[db.seed].enabled = false`; repeat it to seed multiple files or glob + patterns (supabase-relative). Mutually exclusive with `--no-seed`. On the local path + it is forwarded to the recreate seam; on the remote path it seeds the selected + database after migrations (Go warns when paired with `--linked` / `--db-url`). +- `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset target + version becomes `-` (revert everything). Mutually exclusive with `--version`. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. +- **Known interim**: only `--experimental` remote resets run via the Go binary; the + best-effort pg-delta catalog cache (inside the seam) is not surfaced (no output + impact). `encrypted:` vault secrets are skipped on the remote path. diff --git a/apps/cli/src/legacy/commands/db/reset/reset.command.ts b/apps/cli/src/legacy/commands/db/reset/reset.command.ts index 15a61c8d88..979d088f84 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbReset } from "./reset.handler.ts"; +import { legacyDbResetRuntimeLayer } from "./reset.layers.ts"; const noSqlPaths: ReadonlyArray = []; @@ -42,5 +46,24 @@ export type LegacyDbResetFlags = CliCommand.Command.Config.Infer; export const legacyDbResetCommand = Command.make("reset", config).pipe( Command.withDescription("Resets the local database to current migrations."), Command.withShortDescription("Resets the local database to current migrations"), - Command.withHandler((flags) => legacyDbReset(flags)), + Command.withHandler((flags) => + legacyDbReset(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + "no-seed": flags.noSeed, + "sql-paths": flags.sqlPaths, + version: flags.version, + last: flags.last, + }, + // NO safeFlags: `markFlagTelemetrySafe` is per flag INSTANCE, and Go only + // marks migration squash's `--version` (cmd/migration.go:134). db reset's + // `--version` (cmd/db.go) is unmarked, so Go redacts it — match that. + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbResetRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts new file mode 100644 index 0000000000..bfb4c3e539 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -0,0 +1,88 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`cmd/db.go:573`). + */ +export class LegacyDbResetTargetFlagsError extends Data.TaggedError( + "LegacyDbResetTargetFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` and `--last` together. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("version", "last")` (`cmd/db.go:576`). + */ +export class LegacyDbResetVersionFlagsError extends Data.TaggedError( + "LegacyDbResetVersionFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` is not a valid integer. Byte-matches Go's + * `failed to parse : invalid version number` (`repair.go:24-29`). + */ +export class LegacyDbResetInvalidVersionError extends Data.TaggedError( + "LegacyDbResetInvalidVersionError", +)<{ + readonly message: string; +}> {} + +/** + * No migration file matches `--version`. Byte-matches Go's + * `glob supabase/migrations/_*.sql: file does not exist` + * (`repair.GetMigrationFile`). + */ +export class LegacyDbResetMigrationFileError extends Data.TaggedError( + "LegacyDbResetMigrationFileError", +)<{ + readonly message: string; +}> {} + +/** + * The user declined the reset confirmation. Go returns + * `errors.New(context.Canceled)` (`internal/db/reset/reset.go:248`). + */ +export class LegacyDbResetCancelledError extends Data.TaggedError("LegacyDbResetCancelledError")<{ + readonly message: string; +}> {} + +/** A drop / migrate / seed / vault statement failed during the remote reset. */ +export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetApplyError")<{ + readonly message: string; +}> {} + +/** + * The local database container is not running. Byte-matches Go's + * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start + * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local + * reset (`internal/db/reset/reset.go:57`). + */ +export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbResetNotRunningError")<{ + readonly message: string; +}> {} + +/** + * `--last` was given a negative value. Go declares `--last` as an unsigned flag + * (`UintVar`, `cmd/db.go`), so cobra rejects a negative at parse time. Byte-matches + * cobra's parse error for `strconv.ParseUint`. + */ +export class LegacyDbResetLastFlagError extends Data.TaggedError("LegacyDbResetLastFlagError")<{ + readonly message: string; +}> {} + +/** + * Invalid `--sql-paths` usage. Byte-matches Go's `validateDbResetSeedFlags` + * (`cmd/db.go`): `"--no-seed cannot be used with --sql-paths"` and + * `"--sql-paths requires a non-empty path or glob pattern"`. + */ +export class LegacyDbResetSeedFlagsError extends Data.TaggedError("LegacyDbResetSeedFlagsError")<{ + readonly message: string; + /** + * Actionable hint rendered as a `Suggestion:` line, mirroring Go's + * `validateDbResetSeedFlags` `utils.CmdSuggestion` (`cmd/db.go`). + */ + readonly suggestion?: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index de35923ac2..4b5fc92782 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -1,16 +1,480 @@ -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + legacyResolveExperimentalWithProjectEnv, + legacyResolveYesWithProjectEnv, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + legacyCheckDbToml, + legacyLoadProjectEnv, + legacyResolveSeedSqlPath, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyApplyMigrations } from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { + type LegacyDbConnType, + resolveLegacyDbTargetFlags, +} from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { legacyGetPendingSeeds, legacySeedData } from "../shared/legacy-seed-ops.ts"; +import { legacyPathMatch } from "../../../shared/legacy-path-match.ts"; +import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; +import { + LegacyDbResetApplyError, + LegacyDbResetCancelledError, + LegacyDbResetInvalidVersionError, + LegacyDbResetLastFlagError, + LegacyDbResetMigrationFileError, + LegacyDbResetNotRunningError, + LegacyDbResetSeedFlagsError, + LegacyDbResetTargetFlagsError, + LegacyDbResetVersionFlagsError, +} from "./reset.errors.ts"; -export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "reset"]; +const INTEGER_PATTERN = /^[+-]?\d+$/u; +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +const applyError = (message: string) => new LegacyDbResetApplyError({ message }); + +/** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ +const toLogMessage = (version: string): string => + version.length > 0 ? ` to version: ${version}` : "..."; + +/** + * Rebuilds the `db reset` argv for the remaining Go-delegated path: a remote + * `--experimental` reset with no resolved version. Only the flags reachable on + * that path are forwarded — `--local` always takes the native path, and a set + * `--version`/`--last` resolves a non-empty version which disables the experimental + * delegation (a degenerate `--last 0` resolves to "" and is behaviourally identical + * whether or not it is forwarded, so it is omitted). + * + * The target selector is forwarded from the RESOLVED `connType`, not the raw `--linked` + * boolean: the parent's `resolveLegacyDbTargetFlags` follows Cobra's `Changed` semantics, so + * `--linked=false` selects the linked/remote target (this path is remote-only). Forwarding + * only when `flags.linked === true` would drop the selector for `--linked=false` and let the + * Go child fall back to its local default — resetting the wrong database. + */ +const buildResetArgs = ( + flags: LegacyDbResetFlags, + connType: LegacyDbConnType, + yes: boolean, +): Array => { + const args = ["db", "reset"]; if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); + else if (connType === "linked") args.push("--linked"); if (flags.noSeed) args.push("--no-seed"); - for (const path of flags.sqlPaths) args.push("--sql-paths", path); - if (Option.isSome(flags.version)) args.push("--version", flags.version.value); - if (Option.isSome(flags.last)) args.push("--last", String(flags.last.value)); - yield* proxy.exec(args); + for (const p of flags.sqlPaths) args.push("--sql-paths", p); + // Forward the parent's RESOLVED `yes` as a bound flag. Go's `--yes` beats `AutomaticEnv`, + // so `--yes=false` overrides an inherited `SUPABASE_YES=true` (the child no longer + // auto-confirms a reset the user protected with `--yes=false`), while `--yes=true` honors + // an explicit `--yes` / env even in machine mode where the child's stdin is ignored. + // `--yes=false` still prompts on a TTY (Go's PromptYesNo only short-circuits on true), so + // this matches the default behavior when neither flag nor env is set. + args.push(`--yes=${yes}`); + return args; +}; + +/** + * `supabase db reset` — reinitialise a database from local migrations (+ seed). + * + * Strict 1:1 port of `apps/cli-go/internal/db/reset/reset.go`. The remote path + * (`--linked` / a remote `--db-url`) is native. The local path (and the niche + * `--experimental` schema-files path) delegate to the Go binary as a documented + * interim until the container-bootstrap seam is ported (CLI-1325 Stage 3). + */ +export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const proxy = yield* LegacyGoProxy; + const seam = yield* LegacyDbBootstrapSeam; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + + const workdir = cliConfig.workdir; + const migrationsDir = path.join(workdir, "supabase", "migrations"); + // Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-.env key) + // before `reset.Run` reads `viper.GetBool("YES")` / `viper.GetBool("EXPERIMENTAL")`, so a + // `SUPABASE_YES` / `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` is honored. Load the + // project env first and resolve both gates against it, as `db pull` does for `yes`. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"). + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbResetTargetFlagsError({ + 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`, + }), + ); + } + // Go declares `--last` as `UintVar`, so cobra rejects a negative at parse time + // (`Flag.integer` here accepts it). Reject it the same way rather than silently + // treating it as "no --last" and resetting the full history. + if (Option.isSome(flags.last) && flags.last.value < 0) { + return yield* Effect.fail( + new LegacyDbResetLastFlagError({ + message: `invalid argument "${flags.last.value}" for "--last" flag: strconv.ParseUint: parsing "${flags.last.value}": invalid syntax`, + }), + ); + } + // cobra MarkFlagsMutuallyExclusive("version", "last") — alphabetical group. + if (Option.isSome(flags.version) && Option.isSome(flags.last)) { + return yield* Effect.fail( + new LegacyDbResetVersionFlagsError({ + message: + "if any flags in the group [last version] are set none of the others can be; [last version] were all set", + }), + ); + } + + // Go's validateDbResetSeedFlags (PreRunE): `--no-seed` conflicts with + // `--sql-paths`, and each `--sql-paths` value must be non-empty. + if (flags.noSeed && flags.sqlPaths.length > 0) { + return yield* Effect.fail( + new LegacyDbResetSeedFlagsError({ + message: "--no-seed cannot be used with --sql-paths", + suggestion: `Use either ${legacyAqua("--no-seed")} to skip seeding or ${legacyAqua( + "--sql-paths", + )} to override seed files, not both.`, + }), + ); + } + if (flags.sqlPaths.some((p) => p.length === 0)) { + return yield* Effect.fail( + new LegacyDbResetSeedFlagsError({ + message: "--sql-paths requires a non-empty path or glob pattern", + suggestion: `Pass a non-empty file path or glob pattern to ${legacyAqua("--sql-paths")}.`, + }), + ); + } + // Go's warnRemoteResetSeedOverride (PreRunE): a remote target flag + --sql-paths. + if ( + flags.sqlPaths.length > 0 && + (target.setFlags.includes("linked") || target.setFlags.includes("db-url")) + ) { + yield* output.raw( + `${legacyYellow("WARNING:")} --sql-paths overrides [db.seed].sql_paths and seeds the remote database selected by --linked or --db-url.\n`, + "stderr", + ); + } + + // Version / last resolution (Go's reset.Run lines 34-52), filesystem only. + let resolvedVersion = ""; + if (Option.isSome(flags.version)) { + const v = flags.version.value; + if (!INTEGER_PATTERN.test(v)) { + return yield* Effect.fail( + new LegacyDbResetInvalidVersionError({ + message: `failed to parse ${v}: invalid version number`, + }), + ); + } + // Go validates the version with `repair.GetMigrationFile` (repair.go:90-100), + // which globs `supabase/migrations/_*.sql` DIRECTLY with no filtering — + // so a deprecated first migration (e.g. `20200101000000_init.sql`) that + // `legacyListLocalMigrations` excludes is still accepted. Mirror that with a raw + // directory read + Go-glob match instead of the filtered migration listing. + const entries = yield* fs + .readDirectory(migrationsDir) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const found = entries.some( + (name) => legacyPathMatch(`${v}_*.sql`, path.basename(name)).matched, + ); + if (!found) { + return yield* Effect.fail( + new LegacyDbResetMigrationFileError({ + message: `glob supabase/migrations/${v}_*.sql: file does not exist`, + }), + ); + } + resolvedVersion = v; + } else if (Option.isSome(flags.last) && flags.last.value > 0) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const versions = locals.flatMap((p) => { + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined ? [m[1]] : []; + }); + const total = versions.length; + const last = flags.last.value; + resolvedVersion = last < total ? versions[total - last - 1]! : "-"; + } + + const connType = target.connType ?? "local"; + // Single source of truth for "does this reset delegate to the Go child?" — + // checked at both delegation sites below (before `resolve()` for a linked + // target, after it for a `--db-url` target) so the two call sites can never + // drift apart. + const shouldDelegateExperimental = experimental && resolvedVersion === ""; + + // Delegates the remaining `--experimental` schema-files apply path + // (`apply.MigrateAndSeed`, not ported) to the Go child. In text mode inherit + // its stdio. Under a machine-output mode (`--output-format json|stream-json`) + // the Go child emits no TS envelope, so suppress its stdout (capture + discard) + // and emit the same structured success the native local and remote paths do, + // keeping the JSON contract consistent across all reset paths. + const delegateExperimentalReset = () => + Effect.gen(function* () { + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + if (output.format === "text") { + yield* proxy.exec(buildResetArgs(flags, connType, yes), { env }); + } else { + // Machine-output mode is non-interactive: give the Go child a non-TTY stdin + // (`stdin: "ignore"`) so it can't block on (or be answered at) Go's + // destructive reset prompt — it takes the default `false`, matching the + // native reset path which suppresses prompts under json/stream-json. + yield* proxy.execCapture(buildResetArgs(flags, connType, yes), { env, stdin: "ignore" }); + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + }); + + // Go's ParseDatabaseConfig runs LoadProjectRef BEFORE the fallible linked + // resolution (db_url.go:87-95), and Execute() writes the linked-project cache + // even when a later step errors (root.go:171-181). Pre-load the ref so the + // post-run cache finalizer still fires when resolve fails mid-way (merged + // config, temp-role mint, connection) — mirrors push.handler. + if (connType === "linked") { + const refResolver = yield* LegacyProjectRefResolver; + linkedRefForCache = yield* refResolver.loadProjectRef(Option.none()); + + // A linked target is never local (`resolver.resolve()`'s "linked" branch + // always returns `isLocal: false`), so the delegated-experimental check can + // run BEFORE calling `resolve()`. This matters: for `connType === "linked"`, + // `resolve()` mints/verifies a temporary Postgres login role over the + // Management API — and the delegated Go child re-runs that exact same + // `ParseDatabaseConfig` work itself once delegation happens. Calling + // `resolve()` here would mint the temp role twice for zero downstream use on + // this branch (Go's own reset flow mints it exactly once, as part of the code + // path being delegated to — confirmed against `apps/cli-go/internal/utils/ + // flags/db_url.go`'s `NewDbConfigWithPassword`/`initLoginRole`). CLI-1879. + if (shouldDelegateExperimental) { + yield* delegateExperimentalReset(); + return; + } + } + + const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver }); + + // Local target → native local reset. The container-recreate primitives live + // behind the hidden Go `db __db-bootstrap` seam; TS orchestrates the rest + // (running check, messages, bucket seeding, git-branch line, output shaping). + // Mirrors `internal/db/reset/reset.go:57-77`. + if (cfg.isLocal) { + // Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's + // per-connType `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full + // config validation before `reset.Run` ever reaches `AssertSupabaseDbIsRunning` + // / the destructive `resetDatabase` (`internal/db/reset/reset.go:57-61`). The + // resolver's own local read (above, line 239) already performs the identical + // validation and would already reject a broken config before this point is + // reached — so today this re-validates for its own sake. Repeat it here anyway, + // as an explicit, independent gate (the same pattern `db start` and `db push` + // use), so the "malformed config aborts before the local database is recreated" + // guarantee is enforced by this handler directly and stays covered by a + // handler-level test even if the resolver's own internal read is ever mocked, + // relaxed, or refactored to stop validating. + yield* legacyCheckDbToml(fs, path, workdir); + + // AssertSupabaseDbIsRunning — error if the local db container is down. + const running = yield* seam.isDbRunning(); + if (!running) { + return yield* Effect.fail( + new LegacyDbResetNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }), + ); + } + // resetDatabase: "Resetting local database…" then recreate + migrate + seed. + yield* output.raw(`Resetting local database${toLogMessage(resolvedVersion)}\n`, "stderr"); + yield* seam.recreateDatabase({ + version: resolvedVersion, + noSeed: flags.noSeed, + sqlPaths: flags.sqlPaths, + }); + + // Seed objects from supabase/buckets when storage is up (Go gates buckets on + // an existing, healthy storage container). Reuses the ported seed-buckets + // local path; its summary is suppressed (reset emits its own result). + const storageReady = yield* seam.awaitStorageReady(); + if (storageReady) { + // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune + // confirmations take their defaults instead of blocking on input. + // + // `legacyCheckDbToml` above resolves `env(VAR)` via `legacyLoadProjectEnv`, + // which mirrors Go's full nested-env walk (`.env..local`, + // `.env.local`, `.env.`, `.env`, across both `supabase/` and the + // project root — `pkg/config/config.go:1220-1257`). This reload instead goes + // through `@supabase/config`'s `loadProjectConfig` → `loadProjectEnvironment`, + // which only ever reads `supabase/.env`/`.env.local` plus ambient env + // (`packages/config/src/project.ts:209-245`) — regardless of `goViperCompat`, + // which only widens `env(VAR)` matching, not the file set consulted. So a + // config whose `env(VAR)` reference is backed by e.g. `supabase/.env.development` + // is genuinely Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is + // real ambient env by the time Go resolves it — `config.go:1260-1261`) and + // already passed `legacyCheckDbToml` and the real recreate above, but this + // narrower reload can still reject it. A `LegacySeedConfigLoadError` here is + // that env-file-set gap, not a genuinely invalid config — and recreate already + // dropped/rebuilt the DB, so aborting now would leave the reset half-done; warn + // and skip buckets so `db reset` finishes like Go instead. + yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + // Go loads nested env before `buckets.Run`, so `SUPABASE_YES` in `supabase/.env` + // auto-confirms bucket/vector/analytics prune prompts. Pass the project-env-resolved + // `yes` (the shared runner's own `legacyResolveYes` only sees the shell env). + yes, + }).pipe( + Effect.catchTag("LegacySeedConfigLoadError", (error) => + output.raw( + `${legacyYellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, + "stderr", + ), + ), + ); + } + + // "Finished supabase db reset on branch ." (both Aqua). + const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + yield* output.raw( + `Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`, + "stderr", + ); + if (output.format !== "text") { + yield* output.success("Reset local database.", { + target: "local", + version: resolvedVersion, + }); + } + return; + } + + // Re-confirm `linkedRefForCache` from the now-resolved `cfg.ref` for the native + // remote linked path below (a linked+experimental+versionless target already + // delegated and returned above, before `resolve()` was ever called — see the + // `connType === "linked"` block earlier in this function). A `connType === + // "db-url"` target leaves `linkedRefForCache` as whatever the pre-load block + // set (nothing, for `db-url`), since this assignment only fires when linked. + const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); + if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; + + // Remaining remote target: a `--db-url` pointing at a non-local host (the + // `connType === "linked"` case already delegated above, before `resolve()`, + // without resolving a connection at all). + if (shouldDelegateExperimental) { + yield* delegateExperimentalReset(); + return; + } + + // Single Go-parity config load (`flags.LoadConfig` → `config.Load` + `Validate`): + // decodes the whole config with Go's env-expansion + `strconv.ParseBool` weak typing + // (so `enabled = "env(SEED_ENABLED)"` etc. load like Go), applies `SUPABASE_*` + // AutomaticEnv overrides, merges a matching `[remotes.]` block, and decrypts every + // `encrypted:` secret with the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — + // aborting here (before the destructive prompt / `legacyDropUserSchemas`) on any + // undecryptable/invalid config, exactly like Go's `LoadConfig` before ResetAll. + const configRef = connType === "linked" && linkedRef !== undefined ? linkedRef : undefined; + const toml = yield* legacyCheckDbToml(fs, path, workdir, configRef); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); + } + const vaultSecrets = toml.vault; + + // Go's resetRemote: prompt (default false) → cancel, then ResetAll. + const shouldReset = yield* legacyPromptYesNo( + output, + yes, + "Do you want to reset the remote database?", + false, + ); + if (!shouldReset) { + return yield* Effect.fail(new LegacyDbResetCancelledError({ message: "context canceled" })); + } + yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); + + // Go connects with io.Discard, so NO "Connecting to ... database..." line. + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(cfg.conn, { isLocal: false, dnsResolver }); + // ResetAll: drop user schemas → upsert vault → migrate + seed. + yield* legacyDropUserSchemas(session, applyError); + yield* legacyUpsertVaultSecrets(session, vaultSecrets); + + if (toml.migrationsEnabled) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + // LoadPartialMigrations filter: version === "" || v <= version. + const pending = locals.filter((p) => { + if (resolvedVersion === "") return true; + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined && m[1] <= resolvedVersion; + }); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + } + + // `--no-seed` disables seeding; `--sql-paths` overrides [db.seed].sql_paths + // and force-enables it (Go's applyDbResetSeedFlags). The two are mutually + // exclusive (validated above). + const overrideSeed = flags.sqlPaths.length > 0; + // `--sql-paths` force-enables seeding (Go's applyDbResetSeedFlags); otherwise + // honor `db.seed.enabled` (already `SUPABASE_DB_SEED_ENABLED`-resolved by the reader). + const seedEnabled = overrideSeed || (toml.seed.enabled && !flags.noSeed); + if (seedEnabled) { + // `[db.seed].sql_paths` is already Go-config-resolved (supabase/-joined) by the + // reader; the `--sql-paths` override is resolved here the same way Go's + // `resolveSeedSqlPaths` does, so both feed the glob identical paths. + const seedPaths = overrideSeed + ? flags.sqlPaths.map((p) => legacyResolveSeedSqlPath(path, p)) + : toml.seed.sqlPaths; + const seeds = yield* legacyGetPendingSeeds(session, fs, path, seedPaths, workdir); + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } + // Go's best-effort pgcache catalog warning is not ported (no output impact). + }), + ); + + if (output.format !== "text") { + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index bd533d2adc..48d25519c8 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -1,26 +1,66 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import { CliOutput, Command } from "effect/unstable/cli"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyYesFlag, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; -import { legacyDbResetCommand } from "./reset.command.ts"; +import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbConfigConnectTempRoleError } from "../../../shared/legacy-db-config.errors.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import { legacyDbReset } from "./reset.handler.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; -function setupLegacyDbReset() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; -} +const LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; -const baseFlags: LegacyDbResetFlags = { +const CONN: LegacyPgConnInput = { + host: "db.example.supabase.co", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", +}; + +const DEFAULT_FLAGS: LegacyDbResetFlags = { dbUrl: Option.none(), linked: false, local: false, @@ -30,118 +70,1327 @@ const baseFlags: LegacyDbResetFlags = { last: Option.none(), }; +/** + * Tracks every `resolve`/`resolvePoolerFallback` invocation so tests can prove a + * connection was (or, for the delegated-experimental path, was NOT) resolved — + * `resolve()` mints/verifies a temporary Postgres login role over the Management + * API, so calling it on a path that immediately discards the result is wasted + * (and duplicated) work (CLI-1879). + */ +function mockResolver(opts: { + isLocal: boolean; + ref?: string; + omitRef?: boolean; + resolveFails?: boolean; +}) { + let calls = 0; + const layer = Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => { + calls++; + return opts.resolveFails === true + ? Effect.fail( + new LegacyDbConfigConnectTempRoleError({ + message: "failed to create login role: network error", + }), + ) + : Effect.succeed( + (opts.omitRef === true + ? { conn: CONN, isLocal: opts.isLocal } + : { + conn: CONN, + isLocal: opts.isLocal, + ref: opts.ref !== undefined ? Option.some(opts.ref) : Option.none(), + }) satisfies LegacyResolvedDbConfig, + ); + }, + resolvePoolerFallback: () => { + calls++; + return Effect.succeed(Option.none()); + }, + }); + return { + layer, + get calls() { + return calls; + }, + }; +} + +function mockConnection(opts: { remoteSeeds?: Readonly> }) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string): Effect.Effect => + Effect.sync(() => { + execs.push(sql); + }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === SELECT_SEEDS) { + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === LIST_MIGRATIONS) return Effect.succeed([]); + return Effect.succeed([]); + }, + ), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `storageReady` drives the bucket-seed gate. Records + * the recreate args so tests can assert version / `--no-seed` propagation. + * `awaitStorageReadyExitCode`, when set, fails `awaitStorageReady` with a + * `LegacyGoChildExitError` carrying that code — simulating the seam's real + * `captureStdout` bootstrap-child path exiting non-zero (CLI-1879). + */ +function mockBootstrapSeam(opts: { + running?: boolean; + storageReady?: boolean; + awaitStorageReadyExitCode?: number; +}) { + const recreateCalls: Array<{ + version: string; + noSeed: boolean; + sqlPaths: ReadonlyArray; + }> = []; + let storageChecked = false; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => Effect.succeed(opts.running ?? true), + startDatabase: () => Effect.void, + recreateDatabase: (args: { + version: string; + noSeed: boolean; + sqlPaths: ReadonlyArray; + }) => + Effect.sync(() => { + recreateCalls.push(args); + }), + awaitStorageReady: () => + Effect.sync(() => { + storageChecked = true; + }).pipe( + Effect.flatMap(() => + opts.awaitStorageReadyExitCode !== undefined + ? Effect.fail( + new LegacyGoChildExitError({ + exitCode: opts.awaitStorageReadyExitCode, + message: `failed to bootstrap the local database: exit ${opts.awaitStorageReadyExitCode}`, + }), + ) + : Effect.succeed(opts.storageReady ?? false), + ), + ), + }); + return { + layer, + get recreateCalls() { + return recreateCalls; + }, + get storageChecked() { + return storageChecked; + }, + }; +} + +// Dummy HTTP client; the local-reset bucket-seed core only reaches it when storage +// is ready AND buckets are configured (no reset test configures buckets, so the +// gateway is never actually called). Present to satisfy the handler's R. +const mockStorageHttp = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), + ), +); + +/** + * `execCaptureExitCode`, when set, makes `execCapture` fail with a + * `LegacyGoChildExitError` carrying that code instead of succeeding — simulating + * a delegated Go child exiting non-zero under a machine-output mode (CLI-1879). + */ +function mockProxy(opts: { execCaptureExitCode?: number } = {}) { + const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args, execOpts) => + Effect.sync(() => { + calls.push({ args, env: execOpts?.env }); + }), + execCapture: (args, execOpts) => + Effect.sync(() => { + calls.push({ args, env: execOpts?.env }); + }).pipe( + Effect.flatMap(() => + opts.execCaptureExitCode !== undefined + ? Effect.fail( + new LegacyGoChildExitError({ + exitCode: opts.execCaptureExitCode, + message: `supabase-go exited with code ${opts.execCaptureExitCode}`, + }), + ) + : Effect.succeed(""), + ), + ), + }); + return { + layer, + get calls() { + return calls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + isLocal?: boolean; + ref?: string; + experimental?: boolean; + remoteSeeds?: Readonly>; + yes?: boolean; + omitRef?: boolean; + resolveFails?: boolean; + running?: boolean; + storageReady?: boolean; + awaitStorageReadyExitCode?: number; + execCaptureExitCode?: number; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const proxy = mockProxy({ execCaptureExitCode: opts.execCaptureExitCode }); + const seam = mockBootstrapSeam({ + running: opts.running, + storageReady: opts.storageReady, + awaitStorageReadyExitCode: opts.awaitStorageReadyExitCode, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + // The local-reset bucket-seed core statically requires the (lazy) Management-API + // factory; never invoked on `--local` (projectRef === ""). + const platformApi = mockLegacyPlatformApiService({}); + const resolver = mockResolver({ + isLocal: opts.isLocal ?? false, + ref: opts.ref ?? LEGACY_VALID_REF, + omitRef: opts.omitRef, + resolveFails: opts.resolveFails, + }); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + proxy.layer, + seam.layer, + resolver.layer, + mockLegacyCliConfig({ workdir }), + BunServices.layer, + mockRuntimeInfo(), + // The remote-reset confirmation is answered through mockOutput's + // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is + // only referenced by legacyPromptYesNo's non-TTY branch (unreached here) but must + // be present to satisfy the effect's requirements. + mockTty({ stdinIsTty: true }), + mockStdin(true), + // The linked ref is pre-loaded (for the post-run cache) before resolve, + // mirroring Go's LoadProjectRef-before-NewDbConfigWithPassword order. + Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? LEGACY_VALID_REF)), + loadProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + }), + mockStorageHttp, + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), + }), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "reset", "--linked"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + telemetry.layer, + linkedCache.layer, + ); + return { layer, out, conn, proxy, seam, telemetry, linkedCache, resolver }; +} + +const migrationFile = (version: string, body = "create table t ();") => ({ + [`supabase/migrations/${version}_test.sql`]: body, +}); + describe("legacy db reset", () => { - it.live("forwards the empty-array baseline without seed override flags", () => { - const { layer, calls } = setupLegacyDbReset(); + const tmp = useLegacyTempWorkdir("supabase-db-reset-"); + + it.live("resets the local database via the bootstrap seam", () => { + const { layer, out, seam, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { - yield* legacyDbReset(baseFlags); - expect(calls).toEqual([["db", "reset"]]); - }).pipe(Effect.provide(layer)); + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Native path — no Go delegation. + expect(proxy.calls).toHaveLength(0); + expect(out.stderrText).toContain("Resetting local database..."); + expect(seam.recreateCalls).toEqual([{ version: "", noSeed: false, sqlPaths: [] }]); + // Storage gate checked; with no buckets configured nothing is seeded. + expect(seam.storageChecked).toBe(true); + expect(out.stderrText).toContain("Finished "); + expect(out.stderrText).toContain("on branch "); + }); }); - it.live("forwards --no-seed alone", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("fails a local reset when the database is not running", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: false, + }); return Effect.gen(function* () { - yield* legacyDbReset({ ...baseFlags, noSeed: true }); - expect(calls).toEqual([["db", "reset", "--no-seed"]]); - }).pipe(Effect.provide(layer)); + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); + expect(seam.recreateCalls).toHaveLength(0); + }); }); - it.live("forwards a single --sql-paths flag", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("proceeds with a local reset when no config file is present", () => { + // Go's `Config.Load` tolerates a missing `config.toml`: `Eject` defaults an empty + // `project_id` to the cwd basename (`pkg/config/config.go:563-570`), so `Validate` + // never sees an empty required field and the CLI proceeds — exactly the mechanism + // the cli-e2e parity suite relies on when it runs `db reset --local` from a project + // with no config.toml. A missing config must not become a hard failure here. + const { layer, seam } = setup(tmp.current, { + args: ["db", "reset"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { - yield* legacyDbReset({ - ...baseFlags, - sqlPaths: ["./seeds/base.sql"], + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toHaveLength(1); + }); + }); + + it.live("fails a local reset before the destructive recreate on a malformed config.toml", () => { + // Go's `flags.LoadConfig` (the local target's `LoadConfig`, `db_url.go:77-80`) runs + // full config validation before `reset.Run` reaches `AssertSupabaseDbIsRunning` / + // `resetDatabase` (`internal/db/reset/reset.go:57-61`). A broken config.toml must + // abort before the local database is ever recreated. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live( + "fails a local reset on a malformed config.toml even when the database is not running", + () => { + // Pins Go's exact ordering: `flags.LoadConfig` runs in the root `PersistentPreRunE`, + // strictly before `reset.Run` ever calls `AssertSupabaseDbIsRunning` + // (`internal/db/reset/reset.go:57`). So a broken config must surface as a config + // error even when the local database is ALSO not running — the config check must + // win the race, not the "is not running" check. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + args: ["db", "reset"], + isLocal: true, + running: false, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("failed to load config"); + expect(cause).not.toContain("is not running."); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }, + ); + + it.live("fails a local reset before the destructive recreate on an undecryptable secret", () => { + // Regression: Go's `flags.LoadConfig` decrypts every `encrypted:` secret before + // `reset.Run` recreates the local database, so an undecryptable secret must abort + // before the destructive recreate, not surface later (or never) during bucket + // seeding. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + // Assert on the stable "failed to parse config:" prefix rather than the exact + // decrypt-failure tail, which depends on whether an ambient `DOTENV_PRIVATE_KEY*` + // is set (missing key vs. a base64/decrypt failure) — either way, the config + // load must fail before the destructive recreate. + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse config:"); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live("fails a local reset before the destructive recreate on an empty project_id", () => { + // Go's `config.Validate` rejects an explicit `project_id = ""` (a present override + // that resolved to empty, unlike an absent field) before the local recreate. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = ""\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live("seeds buckets after a local reset when storage is ready", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + // No buckets configured → the seed-buckets core short-circuits, but the + // storage gate is still consulted (Go inspects storage before buckets.Run). + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.storageChecked).toBe(true); + expect(seam.recreateCalls).toHaveLength(1); + }); + }); + + it.live("fails a local reset before the destructive recreate on an unparseable boolean", () => { + // `SEED_ENABLED=maybe` cannot be resolved by Go's `strconv.ParseBool`, so + // `flags.LoadConfig` aborts on this config before `reset.Run` ever reaches + // `AssertSupabaseDbIsRunning`/`resetDatabase`. Previously this surfaced only much + // later (if at all) via the bucket-seeding core's own reload, AFTER the local + // database had already been recreated — this must now abort up front instead, + // via the pre-recreate `legacyCheckDbToml` gate. + const previous = process.env["SEED_ENABLED"]; + process.env["SEED_ENABLED"] = "maybe"; + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("invalid db.seed.enabled"); + } + expect(seam.recreateCalls).toHaveLength(0); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SEED_ENABLED"]; + else process.env["SEED_ENABLED"] = previous; + }), + ), + ); + }); + + it.live( + "finishes a local reset when bucket seeding can't see an env(VAR) value the pre-recreate gate saw", + () => { + // `legacyCheckDbToml` (the pre-recreate gate) resolves `env(VAR)` via + // `legacyLoadProjectEnv`, which mirrors Go's full nested-env walk and sees + // `supabase/.env.development` — a real, Go-valid env source + // (`pkg/config/config.go:1220-1257`; `godotenv.Load` calls `os.Setenv`, so this + // is genuinely ambient env by the time Go itself resolves `env(VAR)`, + // `config.go:1260-1261`). The post-recreate bucket-seed reload instead goes + // through `@supabase/config`'s `loadProjectEnvironment`, which only ever reads + // `supabase/.env`/`.env.local` + ambient env (`packages/config/src/project.ts: + // 209-245) — it can't see `.env.development` at all. So this Go-valid config + // passes the gate and the real recreate, then can't be re-resolved by the + // reload; the reset must still finish (warn-and-skip), not hard-fail after the + // local database has already been dropped and rebuilt. + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', + files: { "supabase/.env.development": "SEED_ENABLED=true\n" }, + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toHaveLength(1); + expect(out.stderrText).toContain("skipped seeding storage buckets"); + expect(out.stderrText).toContain("Finished "); }); - expect(calls).toEqual([["db", "reset", "--sql-paths", "./seeds/base.sql"]]); - }).pipe(Effect.provide(layer)); + }, + ); + + it.live("uses the detected git branch in the Finished line", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + // `detectGitBranch` checks `$GITHUB_HEAD_REF` first (matching Go's + // `GetGitBranchOrDefault`). Set it explicitly so the test is deterministic in + // both a plain checkout and a GitHub Actions PR run (where it is preset to the + // PR branch); restore it afterwards. + const previous = process.env["GITHUB_HEAD_REF"]; + process.env["GITHUB_HEAD_REF"] = "feature-x"; + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // The branch name is wrapped in ANSI (legacyAqua), so assert on the token. + expect(out.stderrText).toContain("on branch "); + expect(out.stderrText).toContain("feature-x"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["GITHUB_HEAD_REF"]; + else process.env["GITHUB_HEAD_REF"] = previous; + }), + ), + ); }); - it.live("forwards repeated --sql-paths flags in order", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("fails a remote reset on a malformed config.toml", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "unterminated\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed + // config aborts with Go's `failed to load config` message, same as the other db + // commands (diff/dump/pull/migration). + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + }); + }); + + it.live("loads a Go-style env() boolean in config for a remote reset", () => { + // Regression: `enabled = "env(VAR)"` must load via Go's env-expansion + ParseBool + // (`legacyCheckDbToml`) instead of the strict @supabase/config loader rejecting it. + const previous = process.env["MIGRATIONS_ENABLED"]; + process.env["MIGRATIONS_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = "env(MIGRATIONS_ENABLED)"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["MIGRATIONS_ENABLED"]; + else process.env["MIGRATIONS_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("emits a json result for a local reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("local"); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--linked", "--local"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("rejects --version together with --last", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + last: Option.some(1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("[last version]"); + }); + }); + + it.live("rejects a non-integer --version", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("not-a-number"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(JSON.stringify(exit.cause)).toContain("invalid version number"); + }); + }); + + it.live("fails when --version has no matching migration file", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "glob supabase/migrations/20240101000000_*.sql: file does not exist", + ); + } + }); + }); + + it.live("returns context canceled when the reset prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + expect(conn.execs).toHaveLength(0); + }); + }); + + it.live("drops schemas and applies migrations + seed on a confirmed remote reset", () => { + const { layer, out, conn, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + // No "Connecting to ... database..." line (Go uses io.Discard). + expect(out.stderrText).not.toContain("Connecting to"); + // Drop block ran, then the migration applied. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect(linkedCache.cached).toBe(true); + }); + }); + + it.live("fails a remote reset before dropping schemas on an undecryptable secret", () => { + // Regression: the old point-of-use vault decryption ran AFTER `legacyDropUserSchemas`, + // so an undecryptable `encrypted:` secret dropped the schemas before failing. Go runs + // `flags.LoadConfig` (which decrypts every secret) before ResetAll, so the reset must + // abort before any destructive work — matched here by `legacyCheckDbToml` at load time. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse config: missing private key"); + } + // Config load failed before ResetAll → schemas were never dropped. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }); + }); + + it.live("fails a remote reset before dropping schemas on an empty project_id", () => { + // Go's config.Validate rejects an explicit `project_id = ""` before the reset prompt, so + // the native remote reset must abort before `legacyDropUserSchemas`. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = ""\n', + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); + } + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }); + }); + + it.live("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { + // Go's loadNestedEnv sets project-.env keys before the reset prompt reads viper YES, so + // a `SUPABASE_YES` in supabase/.env auto-confirms the destructive prompt (default false). + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); + }); + + it.live("still caches the linked ref when DB-config resolution fails", () => { + // Go's Execute() runs ensureProjectGroupsCached after ExecuteC returns even on + // error (root.go:171-181), and ParseDatabaseConfig sets ProjectRef via + // LoadProjectRef BEFORE the fallible temp-role/connection step — so a failed + // linked resolve must not skip the post-run linked-project cache write. + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + resolveFails: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); + }); + + it.live("resets to a specific version, applying only migrations up to it", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); return Effect.gen(function* () { yield* legacyDbReset({ - ...baseFlags, - sqlPaths: ["./seeds/base.sql", "./seeds/demo/*.sql"], + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).not.toContain("Applying migration 20240202000000_test.sql..."); + expect(conn).toBeDefined(); + }); + }); + + it.live("resolves --last to a version prefix", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=1 → revert the most recent → reset to version 20240101000000. + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(1) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + }); + }); + + it.live("reverts all migrations when --last covers the full history", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=2 with 2 local migrations → revert all → version "-". + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(2) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: -"); + }); + }); + + it.live("skips seeding with --no-seed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("delegates an experimental remote reset to the Go binary", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }); + }); + + it.live("does not resolve a linked DB connection before delegating an experimental reset", () => { + const { layer, proxy, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + // The delegated Go child re-runs its own connection resolution (including + // minting/verifying the temp login role) once it starts — the TS wrapper + // must not do that same Management-API work first only to discard it (CLI-1879). + expect(resolver.calls).toBe(0); + }); + }); + + it.live("still caches the linked ref when delegating an experimental reset", () => { + // `linkedRefForCache` is pre-loaded via `LegacyProjectRefResolver.loadProjectRef` + // separately from `resolver.resolve()`, specifically so the post-run + // linked-project-cache finalizer still fires on this path even though + // `resolve()` itself is skipped entirely (CLI-1879). + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); + }); + + it.live( + "surfaces a delegated experimental-reset child failure as a LegacyGoChildExitError under json output", + () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + format: "json", + execCaptureExitCode: 3, + }); + return Effect.gen(function* () { + // Under json/stream-json, the delegated path uses `execCapture` (non-text + // branch of `delegateExperimentalReset`) — this must flow through the normal + // Effect failure channel (reachable by `withJsonErrorHandling` at the + // command-wiring layer) instead of an immediate `ProcessControl.exit()` that a + // handler-level test could never observe (CLI-1879). + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(3); + } }); - expect(calls).toEqual([ - ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], + }, + ); + + it.live( + "propagates the storage-ready check's exact exit code and still flushes telemetry on a local reset", + () => { + // The bootstrap seam's `awaitStorageReady` (the `captureStdout` bootstrap-child + // path) failing non-zero must reach the handler as the exact `LegacyGoChildExitError` + // it fails with, and the handler's own `Effect.ensuring(telemetryState.flush)` + // finalizer must still run despite the typed failure (CLI-1879). + const { layer, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + awaitStorageReadyExitCode: 4, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(4); + } + expect(telemetry.flushed).toBe(true); + }); + }, + ); + + it.live("forwards the linked selector to the delegate even for --linked=false", () => { + // Cobra `Changed` semantics: `--linked=false` still selects the linked/remote target in + // the parent, so the delegated argv must carry `--linked` — otherwise the Go child falls + // back to its local default and resets the wrong database. + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + }); + }); + + it.live("forwards --yes=false to the delegate even when SUPABASE_YES is set", () => { + // Explicit `--yes=false` beats `AutomaticEnv` in Go; the delegated child must receive the + // bound false flag so an inherited `SUPABASE_YES=true` doesn't auto-confirm the reset and + // drop the remote schemas the user tried to protect. + const previous = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "true"; + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=false"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = previous; + }), + ), + ); + }); + + it.live("forwards --yes=true to the delegate when --yes is set", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes"], + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=true"); + }); + }); + + it.live( + "takes the experimental delegate path via SUPABASE_EXPERIMENTAL in the project .env", + () => { + // Go loads nested env before reset.Run reads viper EXPERIMENTAL, so the versionless remote + // reset delegates to the Go binary rather than replaying migrations natively. + const previous = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + const { layer, proxy, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n" }, + // No experimental flag / shell env — only the project .env sets it. + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + // Delegated, so the native remote path never dropped schemas. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = previous; + }), + ), + ); + }, + ); + + it.live("attaches the Go seed-flag conflict suggestion to --no-seed + --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + noSeed: true, + sqlPaths: ["seed.sql"], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + // Go's validateDbResetSeedFlags CmdSuggestion, rendered as a Suggestion: line. + expect(JSON.stringify(exit.cause)).toContain("Use either"); + } + }); + }); + + it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { + const { layer, proxy, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + noSeed: true, + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--db-url", + "postgresql://db.example.com:5432/postgres", + "--no-seed", + "--yes=false", ]); - }).pipe(Effect.provide(layer)); + // Unlike the `connType === "linked"` branch above, a `--db-url` target still + // resolves a connection before delegating — the pre-delegation skip (CLI-1879) + // is scoped to the linked branch only, not "never call resolve when delegating". + expect(resolver.calls).toBe(1); + }); }); - it.live("forwards --no-seed with --sql-paths so Go owns the diagnostic", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("passes --no-seed and the resolved --last version to the recreate seam", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + args: ["db", "reset", "--local"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { + // last=1 with 2 local migrations → recreate up to version 20240101000000. yield* legacyDbReset({ - ...baseFlags, + ...DEFAULT_FLAGS, + local: true, noSeed: true, - sqlPaths: ["./seeds/base.sql"], - }); - expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); - }).pipe(Effect.provide(layer)); + last: Option.some(1), + }).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toEqual([ + { version: "20240101000000", noSeed: true, sqlPaths: [] }, + ]); + }); }); - it.live("forwards an empty --sql-paths value so Go owns the diagnostic", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("recreates to a specific --version on a local db-url reset", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { yield* legacyDbReset({ - ...baseFlags, + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://localhost:54322/postgres"), + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting local database to version: 20240101000000"); + expect(seam.recreateCalls).toEqual([ + { version: "20240101000000", noSeed: false, sqlPaths: [] }, + ]); + }); + }); + + it.live("resets a remote --db-url target without loading a remote config override", () => { + const { layer, out, conn } = setup(tmp.current, { + // No config file → embedded defaults (migrations + seed enabled). + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + isLocal: false, + omitRef: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); + }); + + it.live("announces a matching [remotes.*] override", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + confirm: [true], + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("skips migrations and seed when both are disabled in config", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + // Schemas are still dropped, but nothing is applied or seeded. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).not.toContain("Applying migration"); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("emits a json result for a confirmed remote reset (--yes)", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("remote"); + }); + }); + + it.live("emits a json result for a confirmed remote reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + }); + return Effect.gen(function* () { + // json mode is non-interactive → prompt takes the default (false) → cancel. + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + // default-false prompt in non-text mode declines → context canceled. + expect(Exit.isFailure(exit)).toBe(true); + expect(out).toBeDefined(); + }); + }); + + it.live("rejects --no-seed together with --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + noSeed: true, + sqlPaths: ["seed.sql"], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + } + }); + }); + + it.live("rejects an empty --sql-paths value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, sqlPaths: [""], - }); - expect(calls).toEqual([["db", "reset", "--sql-paths", ""]]); - }).pipe(Effect.provide(layer)); - }); - - it("parses repeated --sql-paths flags from the command surface", async () => { - const { layer, calls } = setupLegacyDbReset(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ - "--sql-paths", - "./seeds/base.sql", - "--sql-paths", - "./seeds/demo/*.sql", - ]); - expect(calls).toEqual([ - ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], - ]); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - layer, - mockOutput({ format: "text" }).layer, - CliOutput.layer(textCliOutputFormatter()), - ), - ), - ) as Effect.Effect, - ); + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--sql-paths requires a non-empty path or glob pattern", + ); + } + }); }); - it("forwards mutually exclusive seed flags from the command surface", async () => { - const { layer, calls } = setupLegacyDbReset(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ - "--no-seed", - "--sql-paths", - "./seeds/base.sql", - ]); - expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - layer, - mockOutput({ format: "text" }).layer, - CliOutput.layer(textCliOutputFormatter()), - ), - ), - ) as Effect.Effect, - ); + it.live("rejects a negative --last value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + last: Option.some(-1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("invalid argument"); + expect(cause).toContain("strconv.ParseUint"); + } + }); + }); + + it.live("seeds an absolute --sql-paths file on a remote reset", () => { + const absSeed = join(tmp.current, "external-seed.sql"); + writeFileSync(absSeed, "insert into t values (3);"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: [absSeed], + }).pipe(Effect.provide(layer)); + // Absolute paths are preserved (not prefixed with supabase/) and seeded. + expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); + }); + }); + + it.live("warns and seeds from --sql-paths overriding config on a remote reset", () => { + const { layer, out } = setup(tmp.current, { + // Seed disabled in config — --sql-paths must force-enable it. + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/custom-seed.sql": "insert into t values (2);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("--sql-paths overrides [db.seed].sql_paths"); + expect(out.stderrText).toContain("Seeding data from supabase/custom-seed.sql..."); + }); + }); + + it.live("forwards --sql-paths to the recreate seam on a local reset", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + sqlPaths: ["custom-seed.sql", "demo/*.sql"], + }).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toEqual([ + { version: "", noSeed: false, sqlPaths: ["custom-seed.sql", "demo/*.sql"] }, + ]); + }); + }); + + it.live("forwards --sql-paths to the Go binary on an experimental remote reset", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--linked", + "--sql-paths", + "custom-seed.sql", + "--yes=false", + ]); + }); }); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts new file mode 100644 index 0000000000..7cf648f3fe --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -0,0 +1,80 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; + +/** + * Runtime layer for `supabase db reset`. Same composition as `db push` / `db lint`: + * the Postgres connection, the db-config resolver, project-ref resolution, and the + * linked-project cache, all over the lazy management-API factory so the local / + * `--db-url` paths never resolve an access token at layer-build time. `LegacyGoProxy` + * (used to delegate the local / experimental reset paths) is ambient from the root. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +export const legacyDbResetRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + httpClient, + credentials, + projectRef, + // Exposed (not just provided to `projectRef`) because the local reset path reuses + // the seed-buckets core, whose `legacyResolveStorageCredentials` requires the + // (lazy) Management-API factory for the linked branch — never hit on `--local`, + // but a static service requirement of the shared core. + platformApiFactory, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + // `legacyPromptYesNo`'s non-TTY branch reads the piped answer via `Stdin` (Go's + // `console.ReadLine`); without it a CI/piped remote `db reset` that reaches the + // confirmation prompt fails with a missing-service defect instead of the default. + stdinLayer, + // Container-recreate / storage-health primitives for the native local reset. + legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)), + commandRuntimeLayer(["db", "reset"]), +); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts index a97de5224d..4ff6cb9ab5 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts @@ -29,8 +29,8 @@ export class LegacyDeclarativeNonInteractiveError extends Data.TaggedError( /** * A mutually-exclusive flag group was violated. Reproduces cobra's * `MarkFlagsMutuallyExclusive` `ValidateFlagGroups` error byte-for-byte: - * - `generate`: `db-url`/`linked`/`local` (`apps/cli-go/cmd/db_schema_declarative.go:499`) - * - `sync`: `apply`/`no-apply` (`apps/cli-go/cmd/db_schema_declarative.go:490`) + * - `generate`: `db-url`/`linked`/`local` (`apps/cli-go/cmd/db_schema_declarative.go:570`) + * - `sync`: `apply`/`no-apply` (`apps/cli-go/cmd/db_schema_declarative.go:561`) * Both fail before any side effects run, matching cobra's pre-RunE validation. */ export class LegacyDeclarativeMutuallyExclusiveFlagsError extends Data.TaggedError( diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts index 506af3485d..bd4777e545 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts @@ -28,11 +28,17 @@ export function legacyPgDeltaSuggestion(configPath: string): string { } /** - * The Effect-CLI replacement for Go's `PersistentPreRunE` gate: invoke at the - * top of each declarative leaf handler. Fails with - * `LegacyDeclarativeNotEnabledError` (carrying the byte-exact message + - * suggestion) when neither `--experimental` nor `[experimental.pgdelta]` enables - * pg-delta. + * The Effect-CLI replacement for Go's `dbDeclarativeCmd.PersistentPreRunE` gate + * (`apps/cli-go/cmd/db_schema_declarative.go:49-99`). Cobra runs + * `PersistentPreRunE` BEFORE `ValidateFlagGroups()` (mutual-exclusivity checks) + * and `RunE` (`cobra@v1.10.2/command.go:985,1010,1014`), so this gate must run + * before the `MarkFlagsMutuallyExclusive` check in the same command — `db-url`/ + * `linked`/`local` on `generate` (`:570`), `apply`/`no-apply` on `sync` (`:561`) + * — a closed gate must win over a flag-group conflict, not the other way + * around. Invoke at the top of each declarative leaf handler's body, before + * that handler's mutex check. Fails with `LegacyDeclarativeNotEnabledError` + * (carrying the byte-exact message + suggestion) when neither `--experimental` + * nor `[experimental.pgdelta]` enables pg-delta. */ export const legacyRequirePgDelta = Effect.fnUntraced(function* (opts: { readonly experimental: boolean; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index ad474a9a77..2b581df3b0 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -41,7 +41,20 @@ function mockEdge(stdout: string) { const layer = Layer.succeed(LegacyEdgeRuntimeScript, { run: (opts: LegacyEdgeRuntimeRunOpts) => { calls.push(opts); - return Effect.succeed({ stdout, stderr: "" }); + // The pg-delta diff script (uniquely identified by `renderPlanFiles`) prints a + // JSON envelope with one file per plan unit; wrap the test's raw SQL into a + // single-unit envelope so `legacyDiffPgDelta` parses it. Other scripts + // (declarative export) return their stdout unchanged. + const wrapped = + opts.script.includes("renderPlanFiles") && stdout.length > 0 + ? JSON.stringify({ + version: 1, + files: [ + { order: 1, name: "schema_changes", transactionMode: "transactional", sql: stdout }, + ], + }) + : stdout; + return Effect.succeed({ stdout: wrapped, stderr: "" }); }, }); return { layer, calls }; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 7e6cbab323..77fb087c26 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -169,9 +169,10 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( } if (shouldReset) { // Go runs reset in-process and returns the error (`cmd/db_schema_declarative.go:262-267`). - // Use the non-exiting seam (not LegacyGoProxy.exec, which process.exits on a - // non-zero child and would skip the handler's telemetry flush / error handling), - // and propagate a failure on a non-zero reset exit. + // `execInherit` (not `LegacyGoProxy.exec`) returns the child's exit code as a + // catchable value rather than exiting the host process — the same + // typed-failure design CLI-1879 gave `LegacyGoProxy.exec` itself, predating + // it here as its own seam. Propagate a failure on a non-zero reset exit. const seam = yield* LegacyDeclarativeSeam; // Forward --network-id: Go's in-process reset.Run honors the root viper // network-id (`apps/cli-go/internal/utils/docker.go:267-271`), so the diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 5df90ac1a9..bd009a4af3 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -47,16 +47,28 @@ pg-delta catalog (source) against the target database's catalog (target). | Code | Condition | | ---- | --------------------------------------------------------------------- | | `0` | success (files written, or skipped after a declined prompt) | -| `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | | `1` | pg-delta not enabled (no `--experimental` / `[experimental.pgdelta]`) | +| `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | | `1` | non-interactive mode with no explicit target | | `1` | shadow-database / edge-runtime / export failure | +The pg-delta gate and the mutex check are both raised before any side effects run, +but the gate wins when both conditions apply simultaneously: Go's +`PersistentPreRunE` runs before `ValidateFlagGroups()` +(`cobra@v1.10.2/command.go:985,1010`), so a closed gate (missing `--experimental`) +surfaces before a `--db-url`/`--linked`/`--local` conflict is ever checked. + ## Output -Text mode only (no machine envelope). Diagnostics + the final -`Declarative schema written to ` go to stderr; the PostRun prints -`Finished supabase db schema declarative generate.` to stdout on success. +Diagnostics (target resolution, prompts, `Declarative schema written to `) +always go to stderr, in every `--output-format`. On success: + +- `text` mode prints `Finished supabase db schema declarative generate.` to + stdout (matches Go's PostRun `fmt.Println`, `cmd/db_schema_declarative.go:116-118`). +- `json`/`stream-json` mode instead emits a structured success envelope + (`output.success("Finished supabase db schema declarative generate.")`) so + the machine stdout payload isn't corrupted by a bare human line + (`generate.command.ts:74-90`, CLI-1546 invariant). ## Notes diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index fbcb89a721..3cec545df7 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -1,8 +1,8 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { - LegacyExperimentalFlag, LegacyYesFlag, + legacyResolveExperimentalWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; @@ -10,6 +10,7 @@ import { LegacyCliConfig } from "../../../../../config/legacy-cli-config.service import { legacyBold } from "../../../../../shared/legacy-colors.ts"; import { legacyReadProjectRefFile } from "../../../../../shared/legacy-temp-paths.ts"; import { + legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, } from "../../../../../shared/legacy-db-config.toml-read.ts"; @@ -44,7 +45,14 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const experimental = yield* LegacyExperimentalFlag; + // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs + // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading + // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ + // db_schema_declarative.go:73-78`, `pkg/config/config.go:789`). Load the project env + // first and resolve against it, as `db reset` does for its own experimental gate, so a + // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); const yes = yield* LegacyYesFlag; // The resolved linked ref (explicit `--linked` only), hoisted so the post-run @@ -52,9 +60,23 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec let linkedProjectRef: string | undefined; yield* Effect.gen(function* () { + const baseToml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // Gate before the mutex check below — order matters; see + // legacyRequirePgDelta's doc comment for why. The pg-delta gate also runs on + // the BASE config: Go's declarative `PersistentPreRunE` gates before the root + // `ParseDatabaseConfig` reloads any `[remotes.]` block, so a remote + // `experimental.pgdelta.enabled = true` must NOT enable a base-disabled + // command without `--experimental`. + yield* legacyRequirePgDelta({ + experimental, + pgDeltaEnabled: baseToml.pgDelta.enabled, + configPath: path.join("supabase", "config.toml"), + }); + // cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` - // (`apps/cli-go/cmd/db_schema_declarative.go:499`) runs before PreRunE/RunE, - // so reject conflicting targets before reading config or the pg-delta gate. + // (`apps/cli-go/cmd/db_schema_declarative.go:570`) runs via + // `ValidateFlagGroups()`, which cobra invokes AFTER `PersistentPreRunE` (the + // gate above) — see legacyRequirePgDelta's doc comment for the full ordering. // "Set" follows cobra's `Changed`: Option set when `Some`, boolean when `true`. const exclusive: Array = []; if (Option.isSome(flags.dbUrl)) exclusive.push("db-url"); @@ -68,17 +90,6 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec ); } - const baseToml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - // The pg-delta gate runs on the BASE config: Go's declarative `PersistentPreRunE` - // gates before the root `ParseDatabaseConfig` reloads any `[remotes.]` block, - // so a remote `experimental.pgdelta.enabled = true` must NOT enable a - // base-disabled command without `--experimental`. - yield* legacyRequirePgDelta({ - experimental, - pgDeltaEnabled: baseToml.pgDelta.enabled, - configPath: path.join("supabase", "config.toml"), - }); - // Explicit `--linked`: Go re-loads config with the resolved ref (root // `ParseDatabaseConfig` linked branch), so a matching `[remotes.]` block // overrides `experimental.pgdelta.*` (declarative_schema_path / format_options) diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 8241336bd5..48ade9b8a6 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -12,6 +12,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, @@ -48,6 +49,7 @@ const EXPORT_JSON = JSON.stringify({ interface SetupOpts { experimental?: boolean; + args?: ReadonlyArray; yes?: boolean; stdinIsTty?: boolean; promptConfirmResponses?: ReadonlyArray; @@ -147,6 +149,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "generate"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), Layer.succeed(LegacyDnsResolverFlag, "native"), @@ -204,10 +207,11 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects conflicting targets (--local --linked) before the pg-delta gate", () => { - // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local") runs before - // PreRunE, so this fails even when pg-delta is not enabled. - const { layer } = setup(tmp.current, { experimental: false }); + it.effect("--local --linked with --experimental fails with the mutex error", () => { + // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs + // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, + // 1010), so the mutex error only surfaces once the gate is open. + const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeGenerate( @@ -223,6 +227,118 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "--local --linked without --experimental fails with the gate error, not the mutex error", + () => { + // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): + // the pg-delta gate runs before the mutex check, so an unopened gate wins even + // when the flags would also violate mutual exclusivity. + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), linked: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--local --linked with SUPABASE_EXPERIMENTAL env (no --experimental flag) fails with the mutex error", + () => { + // Go's gate reads viper.GetBool("EXPERIMENTAL") (db_schema_declarative.go:78), + // which picks up SUPABASE_EXPERIMENTAL via viper.AutomaticEnv (root.go:318-334), + // so an env-only experimental session still opens the gate and lets the mutex + // check fire. legacyResolveExperimental (not the raw LegacyExperimentalFlag) is + // what makes the TS gate honor the env var the same way. + const { layer } = setup(tmp.current, { experimental: false }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), linked: Option.some(true) }), + ), + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "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.effect( + "an explicit --experimental=false closes the gate even when SUPABASE_EXPERIMENTAL is set", + () => { + // viper's bound-pflag lookup returns the flag value whenever Changed is true — + // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an + // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the + // gate instead of letting the env value override it. + const { layer } = setup(tmp.current, { + experimental: false, + args: ["db", "schema", "declarative", "generate", "--experimental=false"], + }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--local --linked with SUPABASE_EXPERIMENTAL set only in the project .env fails with the mutex error", + () => { + // Go's flags.LoadConfig runs loadNestedEnv (which os.Setenv's each project-.env key) + // before dbDeclarativeCmd.PersistentPreRunE reads viper.GetBool("EXPERIMENTAL") + // (apps/cli-go/cmd/db_schema_declarative.go:73-78, pkg/config/config.go:789), so a + // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex + // check fire, same as the shell-env case above. + const saved = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), linked: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "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), + Effect.ensuring( + Effect.sync(() => { + if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = saved; + }), + ), + ); + }, + ); + it.effect("explicit --local: provisions baseline, exports, writes declarative files", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 53d1a64fad..e2182911be 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -23,12 +23,12 @@ as a new timestamped migration. ## Subprocesses / Containers -| What | When | -| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| `supabase-go db schema declarative __catalog --mode migrations --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply migrations → catalog | always | -| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | -| Edge-runtime container running the pg-delta diff Deno script | always | -| `supabase-go migration up --local` | when the migration is applied (`--apply` / prompt / `--yes`) | +| What | When | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `supabase-go db schema declarative __catalog --mode migrations --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply migrations → catalog | always | +| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | +| Edge-runtime container running the pg-delta diff Deno script | always | +| `supabase-go db reset --local [--network-id ]` (seam) — only on the failed-apply recovery path; `db reset` is still Go-proxied (`wrapped`), so the reset itself shells out to the bundled binary | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables @@ -42,14 +42,20 @@ as a new timestamped migration. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------ | -| `0` | success (migration created, applied, or "No schema changes found") | -| `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | -| `1` | pg-delta not enabled | -| `1` | no declarative schema files found | -| `1` | shadow-database / edge-runtime / diff failure | -| `1` | apply failure (when applied) — propagated from `migration up` | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------- | +| `0` | success (migration created, applied, or "No schema changes found") | +| `1` | pg-delta not enabled | +| `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | +| `1` | no declarative schema files found | +| `1` | shadow-database / edge-runtime / diff failure | +| `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | + +The pg-delta gate and the mutex check are both raised before any side effects run, +but the gate wins when both conditions apply simultaneously: Go's +`PersistentPreRunE` runs before `ValidateFlagGroups()` +(`cobra@v1.10.2/command.go:985,1010`), so a closed gate (missing `--experimental`) +surfaces before an `--apply`/`--no-apply` conflict is ever checked. ## Output diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts index e06c092a1f..db9da924de 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts @@ -33,7 +33,7 @@ const config = { Flag.optional, ), // cobra's `MarkFlagsMutuallyExclusive("apply", "no-apply")` keys off `flag.Changed`, - // not the value (`cmd/db_schema_declarative.go:490`), so model presence with `Option` + // not the value (`cmd/db_schema_declarative.go:561`), so model presence with `Option` // so `--apply=false --no-apply` still trips the conflict. The apply decision below // reads the resolved value via `Option.getOrElse`. apply: Flag.boolean("apply").pipe( diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 61f276e4c0..63f8d8a7e4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -2,9 +2,9 @@ import { Cause, Clock, Effect, Exit, FileSystem, Option, Path } from "effect"; import { LegacyDnsResolverFlag, - LegacyExperimentalFlag, LegacyNetworkIdFlag, LegacyYesFlag, + legacyResolveExperimentalWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; @@ -13,6 +13,7 @@ import { legacyBold, legacyRed, legacyYellow } from "../../../../../shared/legac import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; import { legacyGetHostname } from "../../../../../shared/legacy-hostname.ts"; import { + legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, } from "../../../../../shared/legacy-db-config.toml-read.ts"; @@ -72,7 +73,14 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara const path = yield* Path.Path; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; - const experimental = yield* LegacyExperimentalFlag; + // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs + // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading + // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ + // db_schema_declarative.go:73-78`, `pkg/config/config.go:789`). Load the project env + // first and resolve against it, as `db reset` does for its own experimental gate, so a + // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); const yes = yield* LegacyYesFlag; const networkId = yield* LegacyNetworkIdFlag; const dnsResolver = yield* LegacyDnsResolverFlag; @@ -89,10 +97,21 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara let linkedProjectRef: string | undefined; yield* Effect.gen(function* () { + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // Gate before the mutex check below — order matters; see + // legacyRequirePgDelta's doc comment for why. + yield* legacyRequirePgDelta({ + experimental, + pgDeltaEnabled: toml.pgDelta.enabled, + configPath: path.join("supabase", "config.toml"), + }); + // cobra `MarkFlagsMutuallyExclusive("apply", "no-apply")` - // (`apps/cli-go/cmd/db_schema_declarative.go:490`) runs before PreRunE/RunE, - // so reject the conflict before reading config or the pg-delta gate, rather - // than letting `--no-apply` silently win in the apply-decision helper. + // (`apps/cli-go/cmd/db_schema_declarative.go:561`) runs via + // `ValidateFlagGroups()`, which cobra invokes AFTER `PersistentPreRunE` (the + // gate above) — see legacyRequirePgDelta's doc comment for the full ordering. + // Reject the conflict here rather than letting `--no-apply` silently win in + // the apply-decision helper. const exclusive: Array = []; if (Option.isSome(flags.apply)) exclusive.push("apply"); if (Option.isSome(flags.noApply)) exclusive.push("no-apply"); @@ -104,12 +123,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); } - const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - yield* legacyRequirePgDelta({ - experimental, - pgDeltaEnabled: toml.pgDelta.enabled, - configPath: path.join("supabase", "config.toml"), - }); // `path.resolve` (not `path.join`) so an absolute `declarative_schema_path` is // used as-is, matching Go's `config.resolve` (which only prefixes the workdir onto // a relative path). `path.join(workdir, abs)` would mangle the absolute path. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index d1e83e7290..fa04d716dd 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -11,6 +11,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, @@ -44,6 +45,7 @@ const EXPORT_JSON = JSON.stringify({ interface SetupOpts { experimental?: boolean; + args?: ReadonlyArray; yes?: boolean; stdinIsTty?: boolean; diffSql?: string; @@ -94,15 +96,33 @@ function setup(workdir: string, opts: SetupOpts = {}) { removeShadowContainer: () => Effect.void, }); const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => - Effect.succeed({ - stdout: - opts.exportJson !== undefined && - runOpts.errPrefix === "error exporting declarative schema" - ? opts.exportJson - : (opts.diffSql ?? ""), - stderr: "", - }), + run: (runOpts: LegacyEdgeRuntimeRunOpts) => { + if ( + opts.exportJson !== undefined && + runOpts.errPrefix === "error exporting declarative schema" + ) { + return Effect.succeed({ stdout: opts.exportJson, stderr: "" }); + } + const diffSql = opts.diffSql ?? ""; + // The pg-delta diff script (uniquely identified by `renderPlanFiles`) prints a + // JSON envelope with one file per plan unit; wrap the test's raw SQL into a + // single-unit envelope so `legacyDiffPgDelta` parses it. + const stdout = + runOpts.script.includes("renderPlanFiles") && diffSql.length > 0 + ? JSON.stringify({ + version: 1, + files: [ + { + order: 1, + name: "schema_changes", + transactionMode: "transactional", + sql: diffSql, + }, + ], + }) + : diffSql; + return Effect.succeed({ stdout, stderr: "" }); + }, }); const dbExec: string[] = []; const dbConn = Layer.succeed(LegacyDbConnection, { @@ -151,6 +171,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "sync"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed( LegacyNetworkIdFlag, @@ -199,10 +220,11 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --apply and --no-apply together before the pg-delta gate", () => { - // cobra MarkFlagsMutuallyExclusive("apply", "no-apply") runs before PreRunE, - // so this fails even when pg-delta is not enabled. - const { layer } = setup(tmp.current, { experimental: false }); + it.effect("--apply and --no-apply together with --experimental fail with the mutex error", () => { + // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs + // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, + // 1010), so the mutex error only surfaces once the gate is open. + const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync( @@ -218,10 +240,122 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "--apply and --no-apply together without --experimental fail with the gate error, not the mutex error", + () => { + // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): + // the pg-delta gate runs before the mutex check, so an unopened gate wins even + // when the flags would also violate mutual exclusivity. + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ apply: Option.some(true), noApply: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--apply and --no-apply together with SUPABASE_EXPERIMENTAL env (no --experimental flag) fail with the mutex error", + () => { + // Go's gate reads viper.GetBool("EXPERIMENTAL") (db_schema_declarative.go:78), + // which picks up SUPABASE_EXPERIMENTAL via viper.AutomaticEnv (root.go:318-334), + // so an env-only experimental session still opens the gate and lets the mutex + // check fire. legacyResolveExperimental (not the raw LegacyExperimentalFlag) is + // what makes the TS gate honor the env var the same way. + const { layer } = setup(tmp.current, { experimental: false }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ apply: Option.some(true), noApply: Option.some(true) }), + ), + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "an explicit --experimental=false closes the gate even when SUPABASE_EXPERIMENTAL is set", + () => { + // viper's bound-pflag lookup returns the flag value whenever Changed is true — + // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an + // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the + // gate instead of letting the env value override it. + const { layer } = setup(tmp.current, { + experimental: false, + args: ["db", "schema", "declarative", "sync", "--experimental=false"], + }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--apply and --no-apply together with SUPABASE_EXPERIMENTAL set only in the project .env fail with the mutex error", + () => { + // Go's flags.LoadConfig runs loadNestedEnv (which os.Setenv's each project-.env key) + // before dbDeclarativeCmd.PersistentPreRunE reads viper.GetBool("EXPERIMENTAL") + // (apps/cli-go/cmd/db_schema_declarative.go:73-78, pkg/config/config.go:789), so a + // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex + // check fire, same as the shell-env case above. + const saved = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ apply: Option.some(true), noApply: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", + }); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = saved; + }), + ), + ); + }, + ); + it.effect("rejects --apply=false --no-apply as a conflict (Go flag.Changed)", () => { // cobra keys the mutex off flag.Changed, so an explicit `--apply=false` still // counts as set and conflicts with `--no-apply`, even though its value is false. - const { layer } = setup(tmp.current, { experimental: false }); + // The gate runs first (see legacyRequirePgDelta's doc comment), so --experimental + // is required here for the mutex error to be the one that surfaces. + const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync( diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts new file mode 100644 index 0000000000..673e78a466 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts @@ -0,0 +1,20 @@ +import { Data } from "effect"; + +/** + * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the + * container-lifecycle primitives that back native `db start` / `db reset --local` + * (create/recreate the local Postgres container, apply the initial schema, the + * storage health gate) are not yet ported to TypeScript. Wraps a failed inspect, + * a missing `supabase-go` binary, or a non-zero seam exit. The seam tees its own + * progress to stderr, so this message is the fallback shown when the subprocess + * dies without surfacing a more specific Go error. + */ +export class LegacyDbBootstrapError extends Data.TaggedError("LegacyDbBootstrapError")<{ + readonly message: string; + /** + * Optional actionable hint rendered as a separate "Suggestion:" line, mirroring + * Go's `utils.CmdSuggestion` — set to the Docker-install hint when the container + * runtime's daemon is unreachable (`AssertServiceIsRunning`, `misc.go:148-154`). + */ + readonly suggestion?: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts new file mode 100644 index 0000000000..d6b1793166 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts @@ -0,0 +1,252 @@ +import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { + LegacyNetworkIdFlag, + LegacyProfileFlag, + legacyResolveExperimental, +} from "../../../../shared/legacy/global-flags.ts"; +import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; +import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; +import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyResolveLocalProjectId, + localDbContainerId, +} from "../../../shared/legacy-docker-ids.ts"; +import { + LEGACY_SUGGEST_DOCKER_INSTALL, + legacyIsDockerDaemonUnreachable, +} from "../../../shared/legacy-docker-suggest.ts"; +import { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "./legacy-db-bootstrap.seam.service.ts"; + +const seamFailure = (message: string) => new LegacyDbBootstrapError({ message }); + +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); +}; + +/** + * Real {@link LegacyDbBootstrapSeam}: drives the bundled `supabase-go`'s hidden + * `db __db-bootstrap --mode ` command. The binary is resolved exactly like + * `LegacyGoProxy` (`resolveBinary`); the child's telemetry is disabled and its + * progress teed to stderr, matching the `db __shadow` seam. `--network-id` and a + * flag-selected `--profile` are forwarded so the spawned containers land on the + * same network and the child re-runs Go's identical config resolution. + */ +export const legacyDbBootstrapSeamLayer = Layer.effect( + LegacyDbBootstrapSeam, + Effect.gen(function* () { + const cliConfig = yield* LegacyCliConfig; + const networkId = yield* LegacyNetworkIdFlag; + const profile = yield* LegacyProfileFlag; + const profileArgs = profile !== "supabase" ? ["--profile", profile] : []; + const networkArgs = Option.isSome(networkId) ? ["--network-id", networkId.value] : []; + // Forward `--experimental` (env-aware) so the seam's `SetupLocalDatabase` / + // `apply.MigrateAndSeed` takes Go's experimental schema-file path on a + // versionless reset/start, matching `viper.GetBool("EXPERIMENTAL")`. + const experimental = yield* legacyResolveExperimental; + const experimentalArgs = experimental ? ["--experimental"] : []; + const spawner = yield* ChildProcessSpawner; + const processControl = yield* ProcessControl; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = resolveBinary(); + + /** + * Run `db __db-bootstrap` with the given mode args. `captureStdout` pipes + * stdout (for the `await-storage` marker); otherwise stdout is inherited. + * Returns the captured stdout (empty when inherited). + */ + const runBootstrap = (modeArgs: ReadonlyArray, captureStdout: boolean) => + Effect.scoped( + Effect.gen(function* () { + if (!("found" in resolved)) { + return yield* Effect.fail( + seamFailure( + "Could not find the supabase-go binary required to bootstrap the local database.", + ), + ); + } + // `runCli` treats `db start`/`db reset` as self-managed and installs no + // global signal handler, and this direct child spawn (unlike + // `LegacyGoProxy.exec`) inherits the foreground process group. Hold + // SIGINT/SIGTERM/SIGHUP with no-op listeners so an interactive Ctrl-C + // during container startup/restore does not default-terminate the TS + // parent out from under the Go child's docker-cleanup path — the parent + // stays blocked on the child's exit and propagates its real status. + // Scoped, so the listeners are removed on completion/failure/interrupt. + yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); + const args = [ + "db", + "__db-bootstrap", + ...modeArgs, + ...networkArgs, + ...profileArgs, + ...experimentalArgs, + ]; + const command = ChildProcess.make(resolved.found, args, { + cwd: cliConfig.workdir, + stdin: "inherit", + stdout: captureStdout ? "pipe" : "inherit", + stderr: "inherit", + extendEnv: true, + // Disable the child's telemetry so the hidden seam never records its + // own `cli_command_executed` on top of the user's TS command, matching + // the `db __shadow` seam and the explicit LegacyGoProxy delegates. + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + detached: false, + }); + if (!captureStdout) { + const exitCode = yield* spawner + .exitCode(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + if (exitCode !== 0) { + // `LegacyGoChildExitError` (not `seamFailure`/`processControl.exit`) so the + // handler's finalizers — `Effect.ensuring(telemetryState.flush)` + the legacy + // command instrumentation — still run (an immediate `process.exit` would skip + // them), AND the child's exact exit code (e.g. 130 after Ctrl-C cleanup) reaches + // `runCli`'s `processControl.exit()` instead of collapsing to a generic 1. The + // child's detailed failure is already on the inherited stderr, so `runCli` + // special-cases this error class to suppress its own normally-would-print + // generic stderr line — Go itself never prints a second line here. CLI-1879. + return yield* Effect.fail( + new LegacyGoChildExitError({ + exitCode, + message: `failed to bootstrap the local database: exit ${exitCode}`, + }), + ); + } + return ""; + } + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + const chunks: Array = []; + yield* Stream.runForEach(handle.stdout, (chunk) => + Effect.sync(() => { + chunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to bootstrap the local database."))); + const exitCode = yield* handle.exitCode.pipe( + Effect.mapError(() => seamFailure("failed to bootstrap the local database.")), + ); + if (exitCode !== 0) { + // See the `!captureStdout` branch above for why `LegacyGoChildExitError` + // replaces `seamFailure` here — same exact-code + finalizer + no-duplicate-line + // reasoning (CLI-1879). + return yield* Effect.fail( + new LegacyGoChildExitError({ + exitCode, + message: `failed to bootstrap the local database: exit ${exitCode}`, + }), + ); + } + return decodeChunks(chunks); + }), + ); + + return LegacyDbBootstrapSeam.of({ + isDbRunning: () => + Effect.scoped( + Effect.gen(function* () { + // Resolve `utils.DbId` exactly as Go does (env → config.toml → workdir + // basename); the config.toml read is best-effort (`validate: false`) since + // the handler has already run Go's `LoadConfig` validation — an invalid + // config would have failed there, so here we only want the `projectId` and + // tolerate a fallback to the workdir basename rather than re-throwing. + const tomlProjectId = yield* legacyReadDbToml(fs, path, cliConfig.workdir, undefined, { + validate: false, + }).pipe( + Effect.map((toml) => toml.projectId), + // The lenient read still surfaces a genuinely unreadable/malformed project + // `.env`; fall back to the workdir basename in that case rather than failing + // the running-check (the handler has already validated config). + Effect.orElseSucceed(() => Option.none()), + ); + const projectId = legacyResolveLocalProjectId( + Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(tomlProjectId), + cliConfig.workdir, + ); + const containerId = localDbContainerId(projectId); + // Go's AssertSupabaseDbIsRunning = ContainerInspect → NotFound ⇒ not + // running. 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", + stderr: "pipe", + extendEnv: true, + }).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const stderrChunks: Array = []; + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError(() => seamFailure("failed to inspect service")), + ); + if (inspectExit === 0) return true; // container exists ⇒ running + + const stderr = decodeChunks(stderrChunks).trim(); + // Only a missing container means "not running". Docker reports this as + // either "No such container" or "No such object" depending on daemon + // version/CLI path (the same pair handled in `shared/functions/serve.ts`). + // Any other inspect failure (e.g. the Docker daemon is down) propagates, + // matching Go's `AssertSupabaseDbIsRunning`. + if (!stderr.includes("No such container") && !stderr.includes("No such object")) { + // Go's `AssertServiceIsRunning` sets `CmdSuggestion = suggestDockerInstall` + // on a daemon-connection failure (`misc.go:148-154`), so a down daemon + // still surfaces the actionable Docker Desktop hint, not just raw stderr. + return yield* Effect.fail( + new LegacyDbBootstrapError({ + message: + stderr.length > 0 + ? `failed to inspect service: ${stderr}` + : "failed to inspect service", + ...(legacyIsDockerDaemonUnreachable(stderr) + ? { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL } + : {}), + }), + ); + } + return false; + }), + ), + startDatabase: ({ fromBackup }) => + runBootstrap( + ["--mode", "start", ...(fromBackup !== undefined ? ["--from-backup", fromBackup] : [])], + false, + ).pipe(Effect.asVoid), + recreateDatabase: ({ version, noSeed, sqlPaths }) => + runBootstrap( + [ + "--mode", + "recreate", + ...(version !== "" ? ["--version", version] : []), + ...(noSeed ? ["--no-seed"] : []), + ...sqlPaths.flatMap((p) => ["--sql-paths", p]), + ], + false, + ).pipe(Effect.asVoid), + awaitStorageReady: () => + runBootstrap(["--mode", "await-storage"], true).pipe( + Effect.map((stdout) => stdout.trim() === "ready"), + ), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts new file mode 100644 index 0000000000..157b290c4b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts @@ -0,0 +1,72 @@ +import { Context, type Effect } from "effect"; + +import type { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; +import type { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; + +/** + * Seam over the bundled Go binary's hidden `db __db-bootstrap` command, exposing + * the container-bootstrap primitives that native `db start` / `db reset --local` + * still need but that are not ported to TypeScript: the local-stack "is running?" + * probe, the database container create/recreate flows, and the storage health gate + * before bucket seeding. The TS handlers orchestrate everything else (user-facing + * messages, version resolution, bucket seeding, the git-branch line, telemetry, + * and `--output-format` shaping); only the Docker lifecycle lives behind here. + * + * Mirrors {@link LegacyDeclarativeSeam} (`db __shadow`): each method shells out to + * the same resolved `supabase-go`, with the child's telemetry disabled so the + * hidden seam never double-counts the user's command, and its progress teed to + * stderr. + */ +interface LegacyDbBootstrapSeamShape { + /** + * Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`): inspect + * the local Postgres container. `true` when it exists (the stack is up), `false` + * when Docker reports "No such container" (Go's `ErrNotRunning`). Any other + * inspect failure (e.g. the Docker daemon is unreachable) fails with + * {@link LegacyDbBootstrapError}, matching Go, which returns the wrapped inspect + * error rather than treating the database as stopped. + */ + readonly isDbRunning: () => Effect.Effect; + /** + * `db start`'s container bootstrap — `start.StartDatabase(fromBackup)` plus Go's + * `DockerRemoveAll` cleanup on failure (`internal/db/start/start.go:54-60`): + * create the Postgres container, wait for health, apply the initial schema + + * roles + migrations + seed on a fresh volume, and write `_current_branch`. + * Progress (`Starting database...`, `Initialising schema...`) is teed to stderr. + */ + readonly startDatabase: (opts: { + readonly fromBackup?: string; + }) => Effect.Effect; + /** + * The PG14/PG15 container-recreate half of local `db reset` + * (`reset.RecreateLocalDatabase`): recreate the db container/volume, init schema, + * migrate + seed up to `version`, and restart the satellite containers. The + * caller has already printed `Resetting local database…`; the seam tees the + * remaining progress (`Recreating database...`, `Restarting containers...`) to + * stderr. `version` is the resolved migration version ("" for all migrations); + * `noSeed` disables the seed and `sqlPaths` overrides `[db.seed].sql_paths` + * inside the recreate's MigrateAndSeed, mirroring the `db reset` + * `--no-seed` / `--sql-paths` handling (`cmd/db.go` `dbResetCmd`). + */ + readonly recreateDatabase: (opts: { + readonly version: string; + readonly noSeed: boolean; + readonly sqlPaths: ReadonlyArray; + }) => Effect.Effect; + /** + * The storage health gate local `db reset` runs before seeding buckets + * (`reset.AwaitStorageReady`): if the storage container exists but is unhealthy, + * wait up to 30s for it. Resolves `true` when the storage container exists (so + * the caller should run the ported bucket seeding) and `false` when it does not + * — matching Go, which silently skips buckets when storage is absent. + */ + readonly awaitStorageReady: () => Effect.Effect< + boolean, + LegacyDbBootstrapError | LegacyGoChildExitError + >; +} + +export class LegacyDbBootstrapSeam extends Context.Service< + LegacyDbBootstrapSeam, + LegacyDbBootstrapSeamShape +>()("supabase/legacy/DbBootstrapSeam") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts new file mode 100644 index 0000000000..88d394498c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts @@ -0,0 +1,169 @@ +import { Effect } from "effect"; + +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; + +/** + * Verbatim port of Go's embedded `pkg/migration/queries/drop.sql` + * (`DropUserSchemas`). A single PL/pgSQL `DO` block that drops user schemas, + * extensions, public-schema objects, and non-managed publications, then + * truncates the auth / supabase_functions / supabase_migrations tables. Run as a + * single simple-query statement, matching Go's one-statement `ExecBatch`. + */ +const DROP_OBJECTS = `do $$ declare + rec record; +begin + -- schemas + for rec in + select pn.* + from pg_namespace pn + left join pg_depend pd on pd.objid = pn.oid + 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 $$;`; + +/** + * Drops all user-created database objects, mirroring Go's + * `migration.DropUserSchemas` (`pkg/migration/drop.go:34-38`): the `drop.sql` `DO` + * block runs as a single transactional statement (no migration-history row). + */ +export const legacyDropUserSchemas = ( + session: LegacyDbSession, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + // Go's `DropUserSchemas` runs only `drop.sql` via `ExecBatch` (drop.go:34-38) — + // no `RESET ALL`. Resetting here would clear caller-supplied DB URL runtime + // params (e.g. `options=-c statement_timeout=…`) before the destructive drop, so + // the remote `db reset --db-url` path must NOT reset (matches Go's ExecBatch). + yield* session.exec("BEGIN"); + yield* session + .exec(DROP_OBJECTS) + .pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + yield* session.exec("COMMIT"); + }).pipe(Effect.mapError((error: LegacyDbExecError) => mapError(error.message))); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts new file mode 100644 index 0000000000..579a0b675a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts @@ -0,0 +1,123 @@ +import { legacyBold } from "../../../shared/legacy-colors.ts"; + +/** + * `pkg/migration/file.go` — local migration filenames are `_.sql`. + * `ListLocalMigrations` guarantees every path in `localMigrations` matches, so the + * version capture group is always present. + */ +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +/** Last path segment, mirroring Go's `filepath.Base`. */ +const baseName = (path: string): string => { + const normalized = path.replace(/[/\\]+$/u, ""); + const slash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + return slash === -1 ? normalized : normalized.slice(slash + 1); +}; + +/** + * `pkg/migration/apply.go:14-16` — the exact error strings Go raises so the legacy + * handler can byte-match them on stderr. + */ +export const LEGACY_ERR_MISSING_REMOTE = + "Found local migration files to be inserted before the last migration on remote database."; +export const LEGACY_ERR_MISSING_LOCAL = + "Remote migration versions not found in local migrations directory."; + +/** + * The outcome of comparing local migration files against the remote + * `schema_migrations` history. Pure 1:1 port of Go's `FindPendingMigrations` + * (`pkg/migration/apply.go:21-54`). + * + * - `ok` — `pending` are the local migration paths to apply (those + * beyond the remote history, in order). + * - `missing-local` — remote has versions with no local file (`ErrMissingLocal`). + * `versions` are the offending remote versions. + * - `missing-remote`— local has files ordered before the remote head + * (`ErrMissingRemote`). `paths` are the out-of-order local + * migration paths. + */ +export type LegacyPendingMigrations = + | { readonly kind: "ok"; readonly pending: ReadonlyArray } + | { readonly kind: "missing-local"; readonly versions: ReadonlyArray } + | { readonly kind: "missing-remote"; readonly paths: ReadonlyArray }; + +/** + * Two-pointer reconciliation of local migration paths vs remote applied versions. + * Mirrors Go's `FindPendingMigrations` exactly, including its **string** + * comparison of versions (`remote == local` / `remote < local`) — version + * prefixes are fixed-width timestamps, so lexical order equals chronological + * order, matching Go. + */ +export function legacyFindPendingMigrations( + localMigrations: ReadonlyArray, + remoteMigrations: ReadonlyArray, +): LegacyPendingMigrations { + const unapplied: Array = []; + const missing: Array = []; + let i = 0; + let j = 0; + while (i < remoteMigrations.length && j < localMigrations.length) { + const remote = remoteMigrations[i]!; + const filename = baseName(localMigrations[j]!); + // ListLocalMigrations guarantees a match, so the capture group is present. + const local = MIGRATE_FILE_PATTERN.exec(filename)![1]!; + if (remote === local) { + i++; + j++; + } else if (remote < local) { + missing.push(remote); + i++; + } else { + // Include out-of-order local migrations. + unapplied.push(localMigrations[j]!); + j++; + } + } + // Ensure all remote versions exist on local. + if (j === localMigrations.length) { + missing.push(...remoteMigrations.slice(i)); + } + if (missing.length > 0) { + return { kind: "missing-local", versions: missing }; + } + // Enforce migrations are applied in chronological order by default. + if (unapplied.length > 0) { + return { kind: "missing-remote", paths: unapplied }; + } + return { kind: "ok", pending: localMigrations.slice(remoteMigrations.length) }; +} + +/** + * Computes the `--include-all` pending set when reconciliation reports + * `missing-remote`. Mirrors Go's `GetPendingMigrations` includeAll branch + * (`internal/migration/up/up.go:46-48`): the out-of-order paths first, then the + * local migrations beyond `len(remote)+len(diff)`. + */ +export function legacyIncludeAllPending( + localMigrations: ReadonlyArray, + remoteCount: number, + diff: ReadonlyArray, +): ReadonlyArray { + return [...diff, ...localMigrations.slice(remoteCount + diff.length)]; +} + +/** + * Go's `suggestRevertHistory` (`internal/migration/up/up.go:55-61`). `fmt.Sprintln` + * appends a trailing newline to each line, so the suggestion ends with `\n`. + */ +export function legacySuggestRevertHistory(versions: ReadonlyArray): string { + return ( + "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n" + + `${legacyBold(`supabase migration repair --status reverted ${versions.join(" ")}`)}\n` + + "\nAnd update local migrations to match remote database:\n" + + `${legacyBold("supabase db pull")}\n` + ); +} + +/** Go's `suggestIgnoreFlag` (`internal/migration/up/up.go:63-67`). */ +export function legacySuggestIgnoreFlag(paths: ReadonlyArray): string { + return ( + "\nRerun the command with --include-all flag to apply these migrations:\n" + + `${legacyBold(paths.join("\n"))}\n` + ); +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts new file mode 100644 index 0000000000..eaf5400d60 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "./legacy-migration-pending.ts"; + +const local = (...versions: ReadonlyArray) => + versions.map((v) => `supabase/migrations/${v}_name.sql`); + +describe("legacyFindPendingMigrations", () => { + it("returns the local migrations beyond the remote history when in sync", () => { + const result = legacyFindPendingMigrations(local("0001", "0002", "0003"), ["0001"]); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0002_name.sql", "supabase/migrations/0003_name.sql"], + }); + }); + + it("is up to date when local and remote match exactly", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0001", "0002"]); + expect(result).toEqual({ kind: "ok", pending: [] }); + }); + + it("reports missing-local when remote has a version with no local file", () => { + const result = legacyFindPendingMigrations(local("0001", "0003"), ["0001", "0002", "0003"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-local for trailing remote versions absent locally", () => { + const result = legacyFindPendingMigrations(local("0001"), ["0001", "0002"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-remote for an out-of-order local migration", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0002"]); + expect(result).toEqual({ + kind: "missing-remote", + paths: ["supabase/migrations/0001_name.sql"], + }); + }); + + it("treats an empty remote history as all-local pending", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), []); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0001_name.sql", "supabase/migrations/0002_name.sql"], + }); + }); +}); + +describe("legacyIncludeAllPending", () => { + it("prepends the out-of-order diff then the migrations beyond remote+diff", () => { + const locals = local("0001", "0002", "0003"); + const diff = ["supabase/migrations/0001_name.sql"]; + // remoteCount 1, diff length 1 → slice from index 2. + expect(legacyIncludeAllPending(locals, 1, diff)).toEqual([ + "supabase/migrations/0001_name.sql", + "supabase/migrations/0003_name.sql", + ]); + }); +}); + +describe("suggestion strings", () => { + it("builds the revert-history suggestion with a trailing newline per line", () => { + expect(legacySuggestRevertHistory(["0002", "0003"])).toContain( + "supabase migration repair --status reverted 0002 0003", + ); + expect(legacySuggestRevertHistory(["0002"])).toMatch(/\n$/u); + expect(legacySuggestRevertHistory(["0002"])).toContain("supabase db pull"); + }); + + it("builds the include-all suggestion listing each path on its own line", () => { + const suggestion = legacySuggestIgnoreFlag([ + "supabase/migrations/0001_a.sql", + "supabase/migrations/0002_b.sql", + ]); + expect(suggestion).toContain("--include-all"); + expect(suggestion).toContain("supabase/migrations/0001_a.sql\nsupabase/migrations/0002_b.sql"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts new file mode 100644 index 0000000000..e02b5c720f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts @@ -0,0 +1,135 @@ +import { Data, Effect, type FileSystem, type Path } from "effect"; + +import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts"; +import { + legacyFormatMigrationTimestamp, + legacyGetMigrationPath, +} from "../../../shared/legacy-migration-file.ts"; + +/** A migration file written by a diff/pull, paired with its history version. */ +export interface LegacyWrittenMigration { + readonly path: string; + readonly version: string; +} + +/** + * A write failure from `legacyWritePgDeltaMigrations`. Callers map this to their + * own command-domain write error (`LegacyDbDiffWriteError` / `LegacyDbPullWriteError`). + */ +export class LegacyPgDeltaMigrationWriteError extends Data.TaggedError( + "LegacyPgDeltaMigrationWriteError", +)<{ + readonly message: string; +}> {} + +/** + * Bounds the base-timestamp bump retry so a directory already full of same-second + * migrations can't spin forever. Mirrors Go's `maxVersionCollisionAttempts`. + */ +const MAX_VERSION_COLLISION_ATTEMPTS = 60; + +/** + * Port of Go's `WritePgDeltaMigrations` (`apps/cli-go/internal/db/diff/pgdelta_migrations.go`). + * + * Writes one ordered migration file per plan unit. A single-unit plan (the common + * case) keeps the exact `_.sql` filename; multi-unit plans append the + * unit name and give each file a strictly increasing timestamp (real time + * arithmetic on the base millis, never string increment) so their execution order + * and migration-history order stay stable. + * + * Before writing anything the FULL set of generated filenames is collision-checked + * against the filesystem: if any target path already exists the base is advanced by + * one second and every version recomputed, so the set stays strictly ascending AND + * unique against pre-existing migrations. The base only ever moves forward — never + * backdated below the caller's wall clock, since backdating could sort a new file + * before pre-existing migrations. The resulting ≤N−1s future-dating is inherent to + * second-granularity versions and acceptable once uniqueness is enforced. + * + * Each file is written with the exclusive `"wx"` flag so a race between the + * collision check and the write can still never silently overwrite an existing + * migration. If any open/write fails mid-loop, every file already written by THIS + * invocation is best-effort removed before the error surfaces (a removal failure + * never masks the original error). + */ +export const legacyWritePgDeltaMigrations = ( + fs: FileSystem.FileSystem, + pathSvc: Path.Path, + opts: { + readonly workdir: string; + readonly baseMillis: number; + readonly name: string; + readonly files: ReadonlyArray<{ readonly name: string; readonly sql: string }>; + }, +): Effect.Effect, LegacyPgDeltaMigrationWriteError> => + Effect.gen(function* () { + const { workdir, name, files } = opts; + const single = files.length === 1; + const buildSet = (baseMillis: number): Array => + files.map((file, i) => { + const version = legacyFormatMigrationTimestamp(baseMillis + i * 1000); + const unitName = single ? name : `${name}_${file.name}`; + return { path: legacyGetMigrationPath(pathSvc, workdir, version, unitName), version }; + }); + + let baseMillis = opts.baseMillis; + let set = buildSet(baseMillis); + for (let attempt = 0; ; attempt++) { + let collision = false; + for (const w of set) { + const exists = yield* fs.exists(w.path).pipe( + Effect.mapError( + (cause) => + new LegacyPgDeltaMigrationWriteError({ + message: `failed to check migration file: ${cause.message}`, + }), + ), + ); + if (exists) { + collision = true; + break; + } + } + if (!collision) break; + if (attempt + 1 >= MAX_VERSION_COLLISION_ATTEMPTS) { + return yield* Effect.fail( + new LegacyPgDeltaMigrationWriteError({ + message: `failed to find a unique migration version after ${MAX_VERSION_COLLISION_ATTEMPTS} attempts`, + }), + ); + } + baseMillis += 1000; + set = buildSet(baseMillis); + } + + const written: Array = []; + const writeAll = Effect.gen(function* () { + for (let i = 0; i < files.length; i++) { + const w = set[i]!; + const file = files[i]!; + yield* legacyMakeDir(fs, pathSvc.dirname(w.path)).pipe( + Effect.mapError( + (cause) => new LegacyPgDeltaMigrationWriteError({ message: cause.message }), + ), + ); + yield* fs.writeFileString(w.path, `${file.sql}\n`, { flag: "wx" }).pipe( + Effect.mapError( + (cause) => + new LegacyPgDeltaMigrationWriteError({ + message: + cause.reason._tag === "AlreadyExists" + ? `failed to open migration file: ${cause.message}` + : `failed to write migration file: ${cause.message}`, + }), + ), + ); + written.push(w); + } + return written; + }); + + return yield* writeAll.pipe( + Effect.tapError(() => + Effect.forEach(written, (w) => fs.remove(w.path).pipe(Effect.ignore), { discard: true }), + ), + ); + }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts index a15f683d02..f06b687000 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts @@ -3,6 +3,7 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyMigrationsReadError } from "../../../shared/legacy-migration.errors.ts"; +import { type LegacyPgDeltaContext, legacyExportCatalogPgDelta } from "./legacy-pgdelta.ts"; /** * Declarative catalog-cache key builders + on-disk catalog resolution, ported @@ -19,6 +20,8 @@ const INIT_SCHEMA_PATTERN = /([0-9]{14})_init\.sql/; const INIT_SCHEMA_CUTOFF = 20211209000000; // `pkg/migration/file.go` — valid migration filenames. const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/; +// `internal/utils/misc.go` — `ProjectHostPattern`, matches a direct `db..supabase.{co,red}` host. +const PROJECT_HOST_PATTERN = /^(db\.)([a-z]{20})\.supabase\.(co|red)$/; /** Inputs to `setupInputsToken` — everything `start.SetupDatabase` consumes. */ export interface LegacySetupInputs { @@ -43,6 +46,28 @@ export function legacySanitizedCatalogPrefix(prefix: string): string { return trimmed.replace(CATALOG_PREFIX_PATTERN, "-"); } +/** + * Mirrors Go's `pgcache.CatalogPrefixFromConfig` (`pgcache/cache.go`): `"local"` + * for the local dev database, the project ref for a direct `db..supabase.*` + * host, else a stable `url-` derived from the connection. + */ +export function legacyCatalogPrefixFromConfig( + conn: { + readonly host: string; + readonly port: number; + readonly user: string; + readonly database: string; + }, + isLocal: boolean, +): string { + if (isLocal) return "local"; + const match = PROJECT_HOST_PATTERN.exec(conn.host); + if (match?.[2] !== undefined) return match[2]; + const key = `${conn.user}@${conn.host}:${conn.port}/${conn.database}`; + const digest = createHash("sha256").update(key, "utf8").digest("hex"); + return `url-${digest.slice(0, 12)}`; +} + /** Mirrors Go's `baselineVersionToken` (`declarative.go:665`): the image tag, or `pg`. */ export function legacyBaselineVersionToken(image: string, majorVersion: number): string { let tag = image.trim(); @@ -167,18 +192,20 @@ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( /** * Mirrors Go's `pgcache.HashMigrations` (`pgcache/cache.go`): for each local - * migration (in list order), hash its path then its contents. Returns full hex. + * migration (in list order), hash its `workdir`-relative path then its + * contents. Returns full hex. */ export const legacyHashMigrations = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, + workdir: string, migrationsDir: string, ) { const migrations = yield* legacyListLocalMigrations(fs, path, migrationsDir); const hash = createHash("sha256"); for (const filePath of migrations) { const contents = yield* fs.readFile(filePath); - hash.update(filePath, "utf8"); + hash.update(path.relative(workdir, filePath), "utf8"); hash.update(contents); } return hash.digest("hex"); @@ -274,18 +301,13 @@ export const legacyResolveDeclarativeCatalogPath = Effect.fnUntraced(function* ( return latestPath; }); -/** - * Removes all but the newest `catalogRetentionCount` declarative catalogs for a - * prefix family. Mirrors Go's `cleanupOldDeclarativeCatalogs` (`declarative.go:610`). - */ -export const legacyCleanupOldDeclarativeCatalogs = Effect.fnUntraced(function* ( +const cleanupOldCatalogsByFamily = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, tempDir: string, - prefix: string, + familyPrefix: string, ) { const entries = yield* listJsonEntries(fs, tempDir); - const familyPrefix = `catalog-${legacySanitizedCatalogPrefix(prefix)}-declarative-`; const files = entries .filter((name) => name.startsWith(familyPrefix) && name.endsWith(".json")) .map((name) => ({ name, timestamp: Option.getOrElse(parseCatalogTimestamp(name), () => 0) })) @@ -298,3 +320,117 @@ export const legacyCleanupOldDeclarativeCatalogs = Effect.fnUntraced(function* ( .pipe(Effect.orElseSucceed(() => undefined)); } }); + +/** + * Removes all but the newest `catalogRetentionCount` declarative catalogs for a + * prefix family. Mirrors Go's `cleanupOldDeclarativeCatalogs` (`declarative.go:610`). + */ +export const legacyCleanupOldDeclarativeCatalogs = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + tempDir: string, + prefix: string, +) { + yield* cleanupOldCatalogsByFamily( + fs, + path, + tempDir, + `catalog-${legacySanitizedCatalogPrefix(prefix)}-declarative-`, + ); +}); + +/** + * Removes all but the newest `catalogRetentionCount` migrations catalogs for a + * prefix family. Mirrors Go's `pgcache.CleanupOldMigrationCatalogs` (`pgcache/cache.go`). + */ +export const legacyCleanupOldMigrationCatalogs = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + tempDir: string, + prefix: string, +) { + yield* cleanupOldCatalogsByFamily( + fs, + path, + tempDir, + `catalog-${legacySanitizedCatalogPrefix(prefix)}-migrations-`, + ); +}); + +/** `catalog--migrations--.json` (Go's `migrationsCatalogName`, `pgcache/cache.go`). */ +export function legacyMigrationCatalogFileName( + prefix: string, + hash: string, + timestampMillis: number, +): string { + return `catalog-${legacySanitizedCatalogPrefix(prefix)}-migrations-${hash}-${timestampMillis}.json`; +} + +/** + * Writes a migrations-catalog snapshot to `/catalog--migrations--.json` + * and prunes older snapshots for the same `(prefix)` family. Mirrors Go's + * `pgcache.WriteMigrationCatalogSnapshot` (`pgcache/cache.go`). + */ +export const legacyWriteMigrationCatalogSnapshot = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + tempDir: string, + prefix: string, + hash: string, + snapshot: string, + timestampMillis: number, +) { + yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); + const filePath = path.join( + tempDir, + legacyMigrationCatalogFileName(prefix, hash, timestampMillis), + ); + yield* fs.writeFileString(filePath, snapshot); + yield* legacyCleanupOldMigrationCatalogs(fs, path, tempDir, prefix); + return filePath; +}); + +/** + * Best-effort caches the migrations catalog for pg-delta after a successful + * `db push` migration apply. Mirrors Go's `pgcache.TryCacheMigrationsCatalog` + * (`pgcache/cache.go`); `enabled` is resolved by the caller since it depends on + * already-loaded config. Reuses `legacyExportCatalogPgDelta` (Go's correct + * `diff/pgdelta.go` `ExportCatalogPgDelta`) rather than porting a second copy, + * so this can't reintroduce the `/workspace` mount bug `pgcache/cache.go` had + * (supabase/cli#5921). + */ +export const legacyTryCacheMigrationsCatalog = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + ctx: LegacyPgDeltaContext, + params: { + readonly enabled: boolean; + readonly targetUrl: string; + readonly conn: { + readonly host: string; + readonly port: number; + readonly user: string; + readonly database: string; + }; + readonly isLocal: boolean; + readonly migrationsDir: string; + readonly nowMillis: number; + }, +) { + if (!params.enabled) return; + const prefix = legacyCatalogPrefixFromConfig(params.conn, params.isLocal); + const hash = yield* legacyHashMigrations(fs, path, ctx.cwd, params.migrationsDir); + const snapshot = yield* legacyExportCatalogPgDelta(ctx, { + targetRef: params.targetUrl, + role: "postgres", + }); + yield* legacyWriteMigrationCatalogSnapshot( + fs, + path, + legacyPgDeltaTempPath(path, ctx.cwd), + prefix, + hash, + snapshot, + params.nowMillis, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts index 9f2e57e3aa..83535b91c0 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts @@ -13,15 +13,19 @@ import { legacyBaselineCatalogFileName, legacyBaselineCatalogKey, legacyBaselineVersionToken, + legacyCatalogPrefixFromConfig, legacyCleanupOldDeclarativeCatalogs, + legacyCleanupOldMigrationCatalogs, legacyDeclarativeCatalogCacheKey, legacyDeclarativeCatalogFileName, legacyHashDeclarativeSchemas, legacyHashMigrations, legacyListLocalMigrations, + legacyMigrationCatalogFileName, legacyResolveDeclarativeCatalogPath, legacySanitizedCatalogPrefix, legacySetupInputsToken, + legacyWriteMigrationCatalogSnapshot, } from "./legacy-pgdelta.cache.ts"; const BASE: LegacySetupInputs = { @@ -216,25 +220,57 @@ describe("legacyListLocalMigrations", () => { }); describe("legacyHashMigrations", () => { - it.effect("hashes path + contents in list order (stable, content-sensitive)", () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const file = join(migrationsDir, "20240101120000_create.sql"); - writeFileSync(file, "create table x();"); - const expected = createHash("sha256") - .update(file, "utf8") - .update(Buffer.from("create table x();")) - .digest("hex"); - return withServices((fs, path) => legacyHashMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); + it.effect( + "hashes the workdir-relative path + contents in list order (stable, content-sensitive)", + () => { + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + const file = join(migrationsDir, "20240101120000_create.sql"); + writeFileSync(file, "create table x();"); + const relPath = join("supabase", "migrations", "20240101120000_create.sql"); + const expected = createHash("sha256") + .update(relPath, "utf8") + .update(Buffer.from("create table x();")) + .digest("hex"); + return withServices((fs, path) => legacyHashMigrations(fs, path, dir, migrationsDir)).pipe( + Effect.tap((hash) => + Effect.sync(() => { + expect(hash).toBe(expected); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "is unaffected by the absolute location of workdir (Go-parity, not machine-specific)", + () => { + const dirA = withTemp(); + const dirB = withTemp(); + const migrationsA = join(dirA, "supabase", "migrations"); + const migrationsB = join(dirB, "supabase", "migrations"); + mkdirSync(migrationsA, { recursive: true }); + mkdirSync(migrationsB, { recursive: true }); + writeFileSync(join(migrationsA, "20240101120000_create.sql"), "create table x();"); + writeFileSync(join(migrationsB, "20240101120000_create.sql"), "create table x();"); + return withServices((fs, path) => + Effect.gen(function* () { + const hashA = yield* legacyHashMigrations(fs, path, dirA, migrationsA); + const hashB = yield* legacyHashMigrations(fs, path, dirB, migrationsB); + expect(hashA).toBe(hashB); }), - ), - ); - }); + ).pipe( + Effect.tap(() => + Effect.sync(() => { + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + }), + ), + ); + }, + ); }); describe("legacyHashDeclarativeSchemas", () => { @@ -281,7 +317,6 @@ describe("legacyResolveDeclarativeCatalogPath + cleanup", () => { const remaining = (yield* fs.readDirectory(tempDir)).filter((n) => n.startsWith("catalog-local-declarative-"), ); - // Retention keeps the 2 newest of the family (300, 200); 100 + other-50 pruned. expect(remaining.sort()).toEqual([ "catalog-local-declarative-h-200.json", "catalog-local-declarative-h-300.json", @@ -290,3 +325,119 @@ describe("legacyResolveDeclarativeCatalogPath + cleanup", () => { ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); }); }); + +describe("legacyCatalogPrefixFromConfig", () => { + const CONN = { host: "127.0.0.1", port: 5432, user: "postgres", database: "postgres" }; + + it("returns 'local' for a local database regardless of host", () => { + expect(legacyCatalogPrefixFromConfig(CONN, true)).toBe("local"); + }); + + it("returns the project ref for a direct db..supabase.{co,red} host", () => { + const ref = "abcdefghijklmnopqrst"; + expect(legacyCatalogPrefixFromConfig({ ...CONN, host: `db.${ref}.supabase.co` }, false)).toBe( + ref, + ); + expect(legacyCatalogPrefixFromConfig({ ...CONN, host: `db.${ref}.supabase.red` }, false)).toBe( + ref, + ); + }); + + it("falls back to a stable url- hash for anything else", () => { + const conn = { + host: "aws-0-us-east-1.pooler.supabase.com", + port: 6543, + user: "postgres.ref", + database: "postgres", + }; + expect(legacyCatalogPrefixFromConfig(conn, false)).toBe( + `url-${sha12(`${conn.user}@${conn.host}:${conn.port}/${conn.database}`)}`, + ); + }); + + it("does not match a host with the wrong ref length or a different TLD", () => { + const conn = { ...CONN, host: "db.tooshort.supabase.co" }; + const digest = createHash("sha256") + .update(`${conn.user}@${conn.host}:${conn.port}/${conn.database}`, "utf8") + .digest("hex"); + expect(legacyCatalogPrefixFromConfig(conn, false)).toBe(`url-${digest.slice(0, 12)}`); + }); +}); + +describe("legacyMigrationCatalogFileName", () => { + it("formats catalog--migrations--.json", () => { + expect(legacyMigrationCatalogFileName("local", "h", 1700)).toBe( + "catalog-local-migrations-h-1700.json", + ); + }); +}); + +describe("legacyWriteMigrationCatalogSnapshot + cleanup", () => { + it.effect( + "writes the snapshot and prunes older migrations catalogs past the retention count", + () => { + const dir = withTemp(); + const tempDir = join(dir, "pgdelta"); + mkdirSync(tempDir, { recursive: true }); + for (const ts of [100, 300, 200]) { + writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); + } + return withServices((fs, path) => + Effect.gen(function* () { + const filePath = yield* legacyWriteMigrationCatalogSnapshot( + fs, + path, + tempDir, + "local", + "h", + '{"snapshot":true}', + 400, + ); + expect(filePath.endsWith("catalog-local-migrations-h-400.json")).toBe(true); + expect(yield* fs.readFileString(filePath)).toBe('{"snapshot":true}'); + const remaining = (yield* fs.readDirectory(tempDir)).filter((n) => + n.startsWith("catalog-local-migrations-"), + ); + expect(remaining.sort()).toEqual([ + "catalog-local-migrations-h-300.json", + "catalog-local-migrations-h-400.json", + ]); + }), + ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + }, + ); + + it.effect("creates the temp dir when it doesn't exist yet", () => { + const dir = withTemp(); + const tempDir = join(dir, "pgdelta"); + return withServices((fs, path) => + Effect.gen(function* () { + yield* legacyWriteMigrationCatalogSnapshot(fs, path, tempDir, "local", "h", "{}", 100); + expect(yield* fs.exists(join(tempDir, "catalog-local-migrations-h-100.json"))).toBe(true); + }), + ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + }); +}); + +describe("legacyCleanupOldMigrationCatalogs", () => { + it.effect("only prunes files matching the given prefix's family", () => { + const dir = withTemp(); + const tempDir = join(dir, "pgdelta"); + mkdirSync(tempDir, { recursive: true }); + for (const ts of [100, 200, 300]) { + writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); + } + writeFileSync(join(tempDir, "catalog-other-migrations-h-50.json"), "{}"); + return withServices((fs, path) => + Effect.gen(function* () { + yield* legacyCleanupOldMigrationCatalogs(fs, path, tempDir, "local"); + const remaining = (yield* fs.readDirectory(tempDir)).sort(); + expect(remaining).toEqual([ + "catalog-local-migrations-h-200.json", + "catalog-local-migrations-h-300.json", + "catalog-other-migrations-h-50.json", + ]); + }), + ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts index e43e9828a8..5a6fb7060b 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts @@ -15,15 +15,15 @@ /** `templates/pgdelta.ts` — diffs SOURCE→TARGET and prints SQL statements. */ export const legacyPgDeltaDiffScript = - 'import {\n createPlan,\n deserializeCatalog,\n formatSqlStatements,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n let statements = result?.plan.statements ?? [];\n if (formatOptions != null) {\n statements = formatSqlStatements(statements, formatOptions);\n }\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: statements.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n for (const sql of statements) {\n console.log(`${sql};`);\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the diff has been written, so the container\n// never exits and the CLI — which follows this container\'s logs — hangs\n// indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; + 'import {\n createPlan,\n deserializeCatalog,\n renderPlanFiles,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nconst parsedFormatOptions = formatOptionsRaw ? JSON.parse(formatOptionsRaw) : undefined;\n// Format the emitted SQL by default with the same sensible settings the\n// declarative export uses (`exportDeclarativeSchema` in @supabase/pg-delta:\n// `{ ...DEFAULT_OPTIONS, maxWidth: 180, keywordCase: "upper", ...userOptions }`),\n// so `db pull` / `db diff` produce readable migrations even when config sets no\n// `[experimental.pgdelta] format_options`. The formatter fills DEFAULT_OPTIONS\n// for missing keys itself, so only the two overrides are passed here. Setting\n// `format_options = "null"` (parsed to `null`) is the explicit opt-out: raw,\n// unformatted statements, mirroring declarative export\'s `formatOptions === null`.\nconst sqlFormatOptions =\n parsedFormatOptions === null\n ? undefined\n : { maxWidth: 180, keywordCase: "upper", ...parsedFormatOptions };\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n // pg-delta >= 1.0.0-alpha.32 groups plan statements into execution-aware\n // `units` with transaction boundaries. `renderPlanFiles` turns those into one\n // numbered SQL file per unit (header comments included). `includeTransactions:\n // false` because the CLI appliers already wrap each migration file in a single\n // transaction (Go\'s implicit ExecBatch / the TS BEGIN/COMMIT wrap), so embedded\n // BEGIN/COMMIT would nest; format options are applied per unit here instead of a\n // manual `formatSqlStatements` pass.\n const files = result\n ? renderPlanFiles(result.plan, {\n includeTransactions: false,\n sqlFormatOptions,\n })\n : [];\n const envelope = files.map((file, index) => ({\n order: index + 1,\n // The unit name is the rendered path minus its numeric prefix and `.sql`\n // extension (e.g. `001_after_enum_values.sql` -> `after_enum_values`).\n name: file.path.replace(/^\\d+_/, "").replace(/\\.sql$/, ""),\n transactionMode: file.unit.transactionMode,\n sql: file.sql,\n }));\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: files.reduce((total, file) => total + file.unit.statements.length, 0),\n fileCount: files.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n console.log(JSON.stringify({ version: 1, files: envelope }));\n} catch (e) {\n console.error(e);\n // Emit a sentinel so the CLI runner can distinguish a real script crash from a\n // successful empty diff, even though the forced-exit non-zero code below is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the diff has been written, so the container\n// never exits and the CLI — which follows this container\'s logs — hangs\n// indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `templates/pgdelta_declarative_export.ts` — exports declarative file payloads. */ export const legacyPgDeltaDeclarativeExportScript = - '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the export has been written, so the\n// container never exits and the CLI — which follows this container\'s logs —\n// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; + '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Emit a sentinel so the CLI runner can distinguish a real script crash from a\n // successful empty export, even though the forced-exit non-zero code below is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the export has been written, so the\n// container never exits and the CLI — which follows this container\'s logs —\n// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `templates/pgdelta_catalog_export.ts` — serializes a catalog snapshot for caching. */ export const legacyPgDeltaCatalogExportScript = - '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n} finally {\n await close();\n}\n// Force close the event loop on the success path too. The connection pool can\n// leave keepalive handles registered even after close() resolves, which keeps\n// the Edge Runtime worker (and therefore the container) alive after the catalog\n// has already been written to stdout. The CLI streams this container\'s logs with\n// Follow:true, so a worker that never exits hangs the parent `__catalog`\n// subprocess — and the declarative-sync command that spawned it — indefinitely\n// at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; + '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n // Emit a sentinel so the CLI runner treats this as a real script crash rather\n // than a successful empty catalog, even though the forced-exit non-zero code is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n // Emit a sentinel so the CLI runner can distinguish a real script crash from a\n // successful empty catalog, even though the forced-exit non-zero code below is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n // Force close event loop\n throw new Error("");\n} finally {\n await close();\n}\n// Force close the event loop on the success path too. The connection pool can\n// leave keepalive handles registered even after close() resolves, which keeps\n// the Edge Runtime worker (and therefore the container) alive after the catalog\n// has already been written to stdout. The CLI streams this container\'s logs with\n// Follow:true, so a worker that never exits hangs the parent `__catalog`\n// subprocess — and the declarative-sync command that spawned it — indefinitely\n// at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `internal/pgdelta/templates/pgdelta_declarative_apply.ts` — applies declarative files to TARGET. */ export const legacyPgDeltaDeclarativeApplyScript = @@ -35,7 +35,7 @@ export const legacyPgDeltaDeclarativeApplyScript = * config field) is absent or empty. Mirrors Go's `DefaultPgDeltaNpmVersion` * (`apps/cli-go/pkg/config/pgdelta_version.go:7`). */ -export const LEGACY_DEFAULT_PG_DELTA_NPM_VERSION = "1.0.0-alpha.27"; +export const LEGACY_DEFAULT_PG_DELTA_NPM_VERSION = "1.0.0-alpha.32"; /** * The literal version baked into the embedded templates above, replaced by diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts index b52d173fbe..6e12afd324 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts @@ -48,6 +48,15 @@ export class LegacyDeclarativeParseOutputError extends Data.TaggedError( readonly message: string; }> {} +/** + * Parsing the pg-delta diff envelope failed. Byte-matches Go's + * `"failed to parse pg-delta diff output: " + err + ":\n" + stderr` + * (`apps/cli-go/internal/db/diff/pgdelta.go`, `parsePgDeltaDiffOutput`). + */ +export class LegacyPgDeltaDiffParseError extends Data.TaggedError("LegacyPgDeltaDiffParseError")<{ + readonly message: string; +}> {} + /** * Materializing the declarative export on disk failed. Byte-matches Go's * `WriteDeclarativeSchemas` errors (`declarative.go:239`): diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts index f6c816c461..c64f9f624a 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts @@ -58,7 +58,20 @@ describe("legacyDiffPgDelta", () => { it.effect( "returns the SQL + stderr and passes the interpolated diff script + env + binds", () => { - const edge = fakeEdgeRuntime({ stdout: "ALTER TABLE x;\n", stderr: "warn" }); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ + version: 1, + files: [ + { + order: 1, + name: "schema_changes", + transactionMode: "transactional", + sql: "-- unit 1\n\nALTER TABLE x;", + }, + ], + }), + stderr: "warn", + }); return legacyDiffPgDelta(CTX, { targetRef: "postgresql://u:p@127.0.0.1:54320/postgres?connect_timeout=10", sourceRef: "supabase/.temp/catalog.json", @@ -67,7 +80,10 @@ describe("legacyDiffPgDelta", () => { }).pipe( Effect.tap((result) => Effect.sync(() => { - expect(result.sql).toBe("ALTER TABLE x;\n"); + // The envelope is parsed into per-unit files and a flattened SQL join. + expect(result.sql).toBe("-- unit 1\n\nALTER TABLE x;"); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.name).toBe("schema_changes"); expect(result.stderr).toBe("warn"); const opts = edge.calls[0]!; expect(opts.errPrefix).toBe("error diffing schema"); @@ -139,6 +155,27 @@ describe("legacyDiffPgDelta", () => { Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), ); }); + + it.effect("fails with LegacyPgDeltaDiffParseError on a malformed envelope", () => { + const edge = fakeEdgeRuntime({ stdout: "not json{", stderr: "boom" }); + return legacyDiffPgDelta(CTX, { + targetRef: "postgresql://t", + sourceRef: "", + schema: [], + formatOptions: "", + }).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDiffParseError"); + const message = (failError(exit) as { message: string }).message; + expect(message).toContain("failed to parse pg-delta diff output"); + expect(message).toContain("boom"); + }), + ), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + ); + }); }); describe("legacyDeclarativeExportPgDelta", () => { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 634c4c7b56..6a52434c89 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -209,9 +209,11 @@ export const legacyDeclarativeSeamLayer = Layer.effect( })(), ) .trim(); - // Only a missing container means "not running" → start it. Any other - // inspect failure (e.g. Docker daemon down) propagates, matching Go. - if (!stderr.includes("No such container")) { + // Only a missing container means "not running" → start it. Docker reports + // this as either "No such container" or "No such object" (the same pair + // handled in `shared/functions/serve.ts`). Any other inspect failure (e.g. + // Docker daemon down) propagates, matching Go. + if (!stderr.includes("No such container") && !stderr.includes("No such object")) { return yield* Effect.fail( new LegacyDeclarativeShadowDbError({ message: @@ -503,6 +505,13 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ); +// Intentionally NOT `LegacyGoChildExitError` (contrast `legacy-db-bootstrap.seam.layer.ts`, +// fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy +// docker/pgdelta child stderr, not a passthrough of a real Go-CLI child the user invoked +// directly — Go itself wraps every shadow-DB failure into a generic error that `cmd/root.go`'s +// `recoverAndExit` exits `1` for, so propagating THIS child's exact exit code would itself +// diverge from Go, and suppressing this message (as `LegacyGoChildExitError` does for its own, +// already-detailed child stderr) would drop the only actionable line the user sees. const failure = (exitCode?: number) => new LegacyDeclarativeShadowDbError({ message: diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts index a7b49c230d..4dc3de042f 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts @@ -19,6 +19,7 @@ import { LegacyDeclarativeEdgeRuntimeError, LegacyDeclarativeEmptyOutputError, LegacyDeclarativeParseOutputError, + LegacyPgDeltaDiffParseError, } from "./legacy-pgdelta.errors.ts"; const PG_DELTA_NPM_REGISTRY_ENV = "PGDELTA_NPM_REGISTRY"; @@ -38,9 +39,32 @@ export interface LegacyDeclarativeOutput { readonly files: ReadonlyArray; } -/** Result of a pg-delta diff: the SQL statements plus edge-runtime stderr. */ +/** + * One execution-aware migration unit from a pg-delta diff plan. Mirrors Go's + * `PgDeltaPlanFile` (`internal/db/diff/pgdelta.go`): a numbered SQL file whose + * header comments record the unit number, transaction mode and boundary reason. + */ +interface LegacyPgDeltaPlanFile { + readonly order: number; + readonly name: string; + readonly transactionMode: string; + readonly sql: string; +} + +/** The pg-delta diff envelope. Mirrors Go's `PgDeltaDiffOutput`. */ +interface LegacyPgDeltaDiffOutput { + readonly version: number; + readonly files: ReadonlyArray; +} + +/** + * Result of a pg-delta diff: the per-unit plan `files`, a `sql` flattening of + * them (kept for `db diff` / declarative callers that consume one blob), and the + * edge-runtime `stderr`. + */ interface LegacyPgDeltaDiffResult { readonly sql: string; + readonly files: ReadonlyArray; readonly stderr: string; } @@ -189,7 +213,27 @@ export const legacyDiffPgDelta = Effect.fnUntraced(function* ( denoVersion: ctx.denoVersion, }) .pipe(Effect.mapError(toDeclarativeEdgeRuntimeError)); - return { sql: result.stdout, stderr: result.stderr } satisfies LegacyPgDeltaDiffResult; + // The template always prints the diff envelope on the success path, even for an + // empty plan (`{"version":1,"files":[]}`); a truly empty stdout means no envelope + // was produced, which we surface as "no changes" rather than a parse error. + // Mirrors Go's `parsePgDeltaDiffOutput` (`internal/db/diff/pgdelta.go`). + if (result.stdout.trim().length === 0) { + return { sql: "", files: [], stderr: result.stderr } satisfies LegacyPgDeltaDiffResult; + } + const envelope = yield* Effect.try({ + try: () => JSON.parse(result.stdout) as LegacyPgDeltaDiffOutput, + catch: (cause) => + new LegacyPgDeltaDiffParseError({ + message: `failed to parse pg-delta diff output: ${ + cause instanceof Error ? cause.message : String(cause) + }:\n${result.stderr}`, + }), + }); + const files = envelope.files ?? []; + // Flatten to one blob for callers that need it; unit header comments keep the + // transaction boundaries visible (mirrors Go's `joinPgDeltaFiles`). + const sql = files.map((file) => file.sql).join("\n\n"); + return { sql, files, stderr: result.stderr } satisfies LegacyPgDeltaDiffResult; }); /** diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts new file mode 100644 index 0000000000..2617aee0c3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts @@ -0,0 +1,314 @@ +import { createHash } from "node:crypto"; +import { Effect, type FileSystem, Option, type Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyCreateSeedTable } from "../../../shared/legacy-migration-history.ts"; +import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "../../../shared/legacy-path-match.ts"; +import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; + +/** + * Seed-history DML, verbatim from Go's `pkg/migration/history.go`. The schema/table + * DDL (with a transaction-scoped lock timeout) lives in `legacyCreateSeedTable`. + */ +const UPSERT_SEED_FILE = + "INSERT INTO supabase_migrations.seed_files(path, hash) VALUES($1, $2) ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash"; +const SELECT_SEED_TABLE = "SELECT path, hash FROM supabase_migrations.seed_files"; + +/** A local seed file resolved from `[db.seed].sql_paths`, with its content hash. */ +export interface LegacySeedFile { + /** Workdir-relative, forward-slashed path (Go's `filepath.ToSlash`). */ + readonly path: string; + /** Lowercase hex SHA-256 of the file content (Go's `NewSeedFile`). */ + readonly hash: string; + /** True when the remote `seed_files` row has a different hash (re-hash only). */ + readonly dirty: boolean; +} + +const META_CHARS = /[*?[\\]/u; + +/** Result of resolving `[db.seed].sql_paths` against the workspace. */ +interface LegacyGlobResult { + /** Workdir-relative, forward-slashed matches, deduplicated in pattern order. */ + readonly files: ReadonlyArray; + /** Per-pattern warnings (`no files matched pattern: …`), joined by Go's `errors.Join`. */ + readonly warning: Option.Option; +} + +/** + * Resolves seed glob patterns to existing files, porting Go's `config.Glob.Files` + * over `fs.Glob` (`pkg/config/config.go:102-124`). Each pattern is first joined + * under the `supabase/` directory (Go resolves `sql_paths` at config load, + * `config.go:884`). Matches per pattern are sorted; the overall result preserves + * first-seen order across patterns. A pattern that matches nothing, or is malformed + * (Go's `path.ErrBadPattern`, e.g. an unterminated `[` class), contributes a warning + * but is not fatal — mirroring `fs.Glob`'s up-front `Match(pattern, "")` validation + * (`io/fs/glob.go`) and the sibling seed pipeline's `legacy-seed.ts:resolveSeedFiles`. + */ +const legacyGlobSeedFiles = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const seen = new Set(); + const files: Array = []; + const errors: Array = []; + + for (const rawPattern of patterns) { + // Patterns arrive already resolved to Go's config-load form (relative entries + // supabase/-joined, absolute preserved) via `legacyResolveSeedSqlPath` — the reader + // for `[db.seed].sql_paths`, the caller for `--sql-paths`. Go's `config.Glob.Files` + // globs those resolved paths without re-prefixing (`config.go:102-124`), so only + // normalize separators here; re-joining `supabase/` would double-prefix. + const pattern = toSlash(rawPattern); + // Go's `fs.Glob` validates the whole pattern up front (`Match(pattern, "")`); a + // malformed glob is reported as `failed to glob files: ` and + // contributes no matches, rather than the misleading "no files matched" below. + if (legacyPathMatch(pattern, "").badPattern) { + errors.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); + continue; + } + const matches = yield* globOne(fs, path, workdir, pattern); + if (matches.length === 0) { + errors.push(`no files matched pattern: ${pattern}`); + continue; + } + for (const match of [...matches].sort()) { + const fp = toSlash(match); + // Go's `GetPendingSeeds` globs via `Glob.SQLFiles`, which `Stat`s each match: a + // directory is expanded to its regular `.sql` files recursively (`walkMatchedDir`, + // sorted) while a file match is kept verbatim (`config.go:157-183`). Without this a + // directory `sql_paths` entry (e.g. `["seeds"]`) would flow into + // `readFileString()` and fail — Go's `db push --include-seed` / remote reset + // seed the directory's SQL children instead. + const matchType = yield* fs.stat(path.isAbsolute(fp) ? fp : path.join(workdir, fp)).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => "File" as const), + ); + if (matchType === "Directory") { + for (const file of yield* legacyWalkSeedSqlFiles(fs, path, workdir, fp)) { + if (!seen.has(file)) { + seen.add(file); + files.push(file); + } + } + continue; + } + if (!seen.has(fp)) { + seen.add(fp); + files.push(fp); + } + } + } + + return { + files, + warning: errors.length > 0 ? Option.some(errors.join("\n")) : Option.none(), + } satisfies LegacyGlobResult; +}); + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +/** Splits a forward-slashed path into its directory prefix and final element. */ +const splitPath = (p: string): { readonly dir: string; readonly file: string } => { + const slash = p.lastIndexOf("/"); + return slash === -1 ? { dir: "", file: p } : { dir: p.slice(0, slash), file: p.slice(slash + 1) }; +}; + +/** Faithful port of Go's `fs.Glob` for one pattern, rooted at `workdir`. */ +const globOne = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + pattern: string, +): Effect.Effect, never> => + Effect.gen(function* () { + // Absolute patterns resolve against the filesystem root (Go preserves absolute + // seed paths); relative ones are rooted at the workdir. + const resolve = (p: string): string => (path.isAbsolute(p) ? p : path.join(workdir, p)); + // No metacharacters: a direct existence check (Go's `fs.Glob` fast path). + if (!META_CHARS.test(pattern)) { + const exists = yield* fs.exists(resolve(pattern)).pipe(Effect.orElseSucceed(() => false)); + return exists ? [pattern] : []; + } + const { dir, file } = splitPath(pattern); + // Resolve the directory level first (recursively if it, too, is a glob). + const dirs = + dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); + const result: Array = []; + for (const d of dirs) { + const absDir = d === "" ? workdir : resolve(d); + const names = yield* fs + .readDirectory(absDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + for (const name of names) { + if (legacyPathMatch(file, name).matched) { + result.push(d === "" ? name : `${d}/${name}`); + } + } + } + return result; + }); + +/** + * Recursively collects the regular `.sql` files under a matched seed directory, porting + * Go's `walkMatchedDir` with the `SQLFiles` include filter (`entry.Type().IsRegular() && + * filepath.Ext(path) == ".sql"`, `config.go:126-131,194-211`). Paths are workdir-relative + * (matching the glob output), forward-slashed, and sorted for deterministic application. + */ +const legacyWalkSeedSqlFiles = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + dir: string, +): Effect.Effect, never> => + Effect.gen(function* () { + const collected: Array = []; + const walk = (rel: string): Effect.Effect => + Effect.gen(function* () { + const absDir = path.isAbsolute(rel) ? rel : path.join(workdir, rel); + const names = yield* fs + .readDirectory(absDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + for (const name of names) { + const childRel = `${rel}/${name}`; + const childType = yield* fs + .stat(path.isAbsolute(childRel) ? childRel : path.join(workdir, childRel)) + .pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => "Unknown" as const), + ); + if (childType === "Directory") { + yield* walk(childRel); + } else if (childType === "File" && childRel.endsWith(".sql")) { + collected.push(toSlash(childRel)); + } + } + }); + yield* walk(dir); + return collected.sort(); + }); + +/** `SELECT path, hash FROM supabase_migrations.seed_files`, `42P01` → empty map. */ +const readRemoteSeeds = (session: LegacyDbSession) => + session.query(SELECT_SEED_TABLE).pipe( + Effect.map((rows) => { + const applied = new Map(); + for (const row of rows) applied.set(String(row["path"]), String(row["hash"])); + return applied; + }), + Effect.catch((error: LegacyDbExecError) => + isUndefinedTable(error) ? Effect.succeed(new Map()) : Effect.fail(error), + ), + ); + +const isUndefinedTable = (error: LegacyDbExecError): boolean => + error.code !== undefined + ? error.code === "42P01" + : /relation .* does not exist/iu.test(error.message) && + !/column .* does not exist/iu.test(error.message); + +/** + * Resolves the pending seed files for `db push --include-seed`. Mirrors Go's + * `GetPendingSeeds` (`pkg/migration/seed.go:34-63`): glob the configured paths + * (warn, don't fail, on empty patterns), read the remote `seed_files` hashes, + * and emit each local file that is new (`dirty=false`) or hash-changed + * (`dirty=true`); files whose hash already matches are skipped. + */ +export const legacyGetPendingSeeds = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const output = yield* Output; + const { files, warning } = yield* legacyGlobSeedFiles(fs, path, patterns, workdir); + if (Option.isSome(warning)) { + yield* output.raw(`WARN: ${warning.value}\n`, "stderr"); + } + const pending: Array = []; + if (files.length === 0) return pending; + + const applied = yield* readRemoteSeeds(session); + for (const file of files) { + // Go's `NewSeedFile` hashes the raw file stream (`io.Copy`, `pkg/migration/file.go:184`), + // so hash the bytes — not a UTF-8-decoded string, which replaces invalid sequences and + // would drift from the Go-recorded `seed_files` hash for a non-UTF-8 seed (SQL_ASCII dump + // / binary COPY payload), spuriously re-running it across a Go ↔ native switch. + const content = yield* fs.readFile(path.isAbsolute(file) ? file : path.join(workdir, file)); + const hash = createHash("sha256").update(content).digest("hex"); + const appliedHash = applied.get(file); + if (appliedHash !== undefined) { + if (appliedHash === hash) continue; // Already applied, unchanged. + pending.push({ path: file, hash, dirty: true }); + continue; + } + pending.push({ path: file, hash, dirty: false }); + } + return pending; +}); + +/** + * Applies pending seed files. Mirrors Go's `SeedData` + `ExecBatchWithCache` + * (`pkg/migration/seed.go:65-83`, `file.go:198-217`): create the `seed_files` + * table, then per file emit the dirty/clean status line and, in one transaction, + * run the file's statements (skipped when dirty — only the hash is refreshed) + * followed by the `seed_files` hash upsert. + */ +export const legacySeedData = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + workdir: string, + path: Path.Path, + seeds: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + if (seeds.length === 0) return; + // Go's `CreateSeedTable` (history.go:54-64) runs `SET lock_timeout = '4s'` + + // schema/table DDL in one implicit transaction, so a conflicting schema/table lock + // fails promptly but the timeout reverts on COMMIT and never leaks into the seed + // SQL run below. `legacyCreateSeedTable` reproduces that with BEGIN + SET LOCAL + + // DDL + COMMIT (creating the schema first so a seed-only run doesn't fail). + yield* legacyCreateSeedTable(session); + for (const seed of seeds) { + yield* output.raw( + seed.dirty + ? `Updating seed hash to ${seed.path}...\n` + : `Seeding data from ${seed.path}...\n`, + "stderr", + ); + // Go's `ExecBatchWithCache` parses the file (read + `SplitAndTrim`) + // UNCONDITIONALLY before the dirty check (`file.go:198-211`), so a dirty seed + // that is unreadable or contains malformed SQL still fails and leaves the + // previous hash — only the queueing of statements is gated on `Dirty`. + const lines = legacySplitAndTrim( + yield* fs.readFileString( + path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), + ), + ); + const statements = seed.dirty ? [] : lines; + yield* session.exec("BEGIN"); + const body = Effect.gen(function* () { + for (const statement of statements) yield* session.exec(statement); + yield* session.query(UPSERT_SEED_FILE, [seed.path, seed.hash]); + yield* session.exec("COMMIT"); + }); + yield* body.pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + } + }).pipe( + Effect.mapError((error) => + mapError( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ? error.message + : String(error), + ), + ), + ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts new file mode 100644 index 0000000000..6f0a1dcb3f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts @@ -0,0 +1,143 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit, FileSystem, Path } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyGetPendingSeeds, legacySeedData } from "./legacy-seed-ops.ts"; + +class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} + +function fakeSeedSession() { + const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; + const session: LegacyDbSession = { + exec: (sql) => { + calls.push({ kind: "exec", sql }); + return Effect.void; + }, + query: (sql) => { + calls.push({ kind: "query", sql }); + return Effect.succeed([]); + }, + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +// Glob matching itself is `legacyPathMatch` (`../../../shared/legacy-path-match.ts`), +// a faithful port of Go's `path.Match` already covered by +// `legacy-path-match.unit.test.ts` (including the `^`-only negation / `!`-is-literal +// rule this file used to duplicate — and get wrong — in a local `legacyMatchPattern`). +// This exercises that the seed pipeline's own glob resolution (`legacyGetPendingSeeds`) +// actually uses it end to end, per Go's `config.Glob.Files` → `fs.Glob` → `path.Match`. +describe("legacyGetPendingSeeds (glob character classes)", () => { + it.effect( + "treats a leading `!` in a bracket class as literal, not negation (Go path.Match parity)", + () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-glob-")); + writeFileSync(join(dir, "a.sql"), "select 1;"); + writeFileSync(join(dir, "b.sql"), "select 2;"); + const { session } = fakeSeedSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Go's `[!a]` is a positive class of the literal members `!` and `a` — only a + // leading `^` negates. So this pattern matches `a.sql`, not `b.sql` (the old + // shell-style bug negated on `!` too, and would have matched `b.sql` instead). + const pending = yield* legacyGetPendingSeeds(session, fs, path, ["[!a].sql"], dir); + expect(pending.map((seed) => seed.path)).toEqual(["a.sql"]); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "warns Go's bad-pattern message for an unterminated bracket class, not a bogus no-match", + () => { + // An unclosed `[` is malformed per Go's `path.Match` grammar (`ErrBadPattern`), which + // `fs.Glob` reports as `failed to glob files: syntax error in pattern` — not the + // generic `no files matched pattern` a same-shaped but well-formed glob would get. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-glob-")); + const { session } = fakeSeedSession(); + const out = mockOutput({ format: "text" }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pending = yield* legacyGetPendingSeeds(session, fs, path, ["seed[.sql"], dir); + expect(pending).toEqual([]); + expect(out.rawChunks.map((c) => c.text).join("")).toContain( + "failed to glob files: syntax error in pattern", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(out.layer), Effect.provide(BunServices.layer)); + }, + ); +}); + +const runSeed = ( + session: LegacyDbSession, + workdir: string, + seeds: ReadonlyArray<{ readonly path: string; readonly hash: string; readonly dirty: boolean }>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacySeedData( + session, + fs, + workdir, + path, + seeds, + (message) => new TestError({ message }), + ); + }).pipe(Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(BunServices.layer)); + +describe("legacySeedData (dirty parse)", () => { + it.effect("fails on an unreadable dirty seed instead of refreshing its hash", () => { + // Go's `ExecBatchWithCache` reads + parses the file UNCONDITIONALLY before the + // dirty check, so a dirty seed pointing at a missing file must fail (and leave + // the previous hash) rather than silently upserting the new hash. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + const { session, calls } = fakeSeedSession(); + return runSeed(session, dir, [{ path: "missing.sql", hash: "newhash", dirty: true }]).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + // The hash upsert is a `query`; the only execs that ran are the + // schema/table creation (whose DDL also mentions `seed_files`), so assert + // no `query` ran rather than substring-matching the table name. + expect(calls.some((c) => c.kind === "query")).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("refreshes the hash for a dirty seed that parses, without running statements", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + writeFileSync(join(dir, "data.sql"), "insert into t values (1);"); + const { session, calls } = fakeSeedSession(); + return runSeed(session, dir, [{ path: "data.sql", hash: "newhash", dirty: true }]).pipe( + Effect.tap(() => + Effect.sync(() => { + // Go's CreateSeedTable scopes the lock timeout to the DDL transaction + // (BEGIN + SET LOCAL + COMMIT) so it never leaks into the seed SQL below. + expect(calls.some((c) => c.sql === "SET LOCAL lock_timeout = '4s'")).toBe(true); + // Statements are NOT executed for a dirty seed, but the hash IS upserted. + expect(calls.some((c) => c.sql.includes("insert into t"))).toBe(false); + expect(calls.some((c) => c.kind === "query" && c.sql.includes("seed_files"))).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 0c980a749c..dacbedff73 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -1,17 +1,35 @@ # `supabase db start` +Native TS port of `apps/cli-go/internal/db/start/start.go` `Run`. The handler +validates config, checks whether the local Postgres container is already running, +and otherwise delegates the container bootstrap to the bundled Go binary's hidden +`db __db-bootstrap --mode start` seam (the container-lifecycle primitives are not +ported). This is `db start`, **not** the top-level `supabase start`: no status +table, no `cli_stack_started` event, no `Finished` line. + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve local DB config | -| `` (from `--from-backup`) | binary | when `--from-backup` flag is set | +| Path | Format | When | +| -------------------------------- | ------ | --------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — parsed up front; a malformed config aborts before work | +| `` (from `--from-backup`) | binary | when `--from-backup` is set (read by the Go seam on start) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | --------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | by the Go seam (`initCurrentBranch`) when starting; writes `main` if absent | +| local Docker volume `supabase_db_` | — | by the Go seam — the Postgres data volume created on first start | +| `~/.supabase/telemetry.json` | JSON | always (telemetry flush, success and failure) | + +## Subprocesses + +| Command | When | Purpose | +| ---------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | always | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode start [--from-backup

]` | when the database is not running | create container + health check + initial schema/roles/migrations/seed + `_current_branch`; telemetry disabled (`SUPABASE_TELEMETRY_DISABLED=1`), progress teed to stderr | + +`--network-id` and a flag-selected `--profile` are forwarded to the seam. ## API Routes @@ -19,34 +37,49 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +(The Go seam may call Auth's JWKS endpoint while applying service migrations on a +fresh PG15 volume; that is internal to the seam, not the TS handler.) + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| ----------------------------- | ---------------------------------------------------- | ---------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| `SUPABASE_TELEMETRY_DISABLED` | set on the seam subprocess so it never double-counts | (internal) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------ | -| `0` | success | -| `1` | Docker not running | -| `1` | database container start error | +| Code | Condition | +| -------------------- | --------------------------------------------------------------------- | +| `0` | success — database started, or already running | +| `1` | malformed `supabase/config.toml` | +| `1` | Docker daemon unreachable / inspect failure | +| child's exact code\* | container bootstrap failed (the seam cleans up via `DockerRemoveAll`) | + +\* The `db __db-bootstrap` seam propagates the spawned `supabase-go` child's +real exit code (e.g. `130` after a Ctrl-C mid-bootstrap) instead of collapsing +every failure to `1` — in every `--output-format` (CLI-1879). ## Output ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as the local Postgres container starts. +- Already running → `Postgres database is already running.` on **stderr**, exit 0. +- Starting → the Go seam tees `Starting database...` / `Initialising schema...` to + **stderr**. No stdout output, no `Finished` line. ### `--output-format json` -Not applicable. +Emits a single result object to stdout: `{ status: "already-running" }` or +`{ status: "started" }`. Progress stays on stderr. ### `--output-format stream-json` -Not applicable. +Same result object as the terminal `result` event; progress on stderr. ## Notes -- `--from-backup` restores the database from a logical backup file on start. +- `--from-backup` restores the database from a logical backup file on start; the + health check is skipped for backups (a large restore can exceed the timeout). +- No `cli_stack_started` telemetry — that event belongs to `supabase start`, not + `db start`. The only event is the standard `cli_command_executed`. diff --git a/apps/cli/src/legacy/commands/db/start/start.command.ts b/apps/cli/src/legacy/commands/db/start/start.command.ts index cc4081ae84..c9457733ae 100644 --- a/apps/cli/src/legacy/commands/db/start/start.command.ts +++ b/apps/cli/src/legacy/commands/db/start/start.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbStart } from "./start.handler.ts"; +import { legacyDbStartRuntimeLayer } from "./start.layers.ts"; const config = { fromBackup: Flag.string("from-backup").pipe( @@ -14,5 +18,15 @@ export type LegacyDbStartFlags = CliCommand.Command.Config.Infer; export const legacyDbStartCommand = Command.make("start", config).pipe( Command.withDescription("Starts local Postgres database."), Command.withShortDescription("Starts local Postgres database"), - Command.withHandler((flags) => legacyDbStart(flags)), + Command.withHandler((flags) => + legacyDbStart(flags).pipe( + withLegacyCommandInstrumentation({ + // `--from-backup` is not telemetry-safe in Go (no markFlagTelemetrySafe), + // so a set value reaches telemetry as ``. + flags: { "from-backup": flags.fromBackup }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbStartRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/start/start.handler.ts b/apps/cli/src/legacy/commands/db/start/start.handler.ts index a1aa3e586a..7428173f1a 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -1,10 +1,85 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; +/** + * `supabase db start` — start the local Postgres database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/start/start.go` `Run`. Native TS + * orchestrates: it validates config, checks whether the database is already + * running (printing Go's "already running" line), and otherwise delegates the + * container bootstrap to the hidden Go `__db-bootstrap` seam (create container + + * health + initial schema + `_current_branch`), whose progress is teed to stderr. + * + * Parity notes: this is `db start`, NOT the top-level `supabase start`. It does + * NOT print a status table and does NOT fire `cli_stack_started` — those belong to + * `internal/start/start.go`. There is no `Finished` line. + */ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: LegacyDbStartFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "start"]; - if (Option.isSome(flags.fromBackup)) args.push("--from-backup", flags.fromBackup.value); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const seam = yield* LegacyDbBootstrapSeam; + const telemetryState = yield* LegacyTelemetryState; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + + const body = Effect.gen(function* () { + // Go's `flags.LoadConfig(fsys)` runs first thing in `start.Run` + // (`internal/db/start/start.go:45`): a missing config is tolerated (defaults), but + // a present config that is malformed, references an undecryptable `encrypted:` + // secret, or fails Validate aborts before any container work. `legacyCheckDbToml` + // is that exact load+validate — call it here (not via the seam's best-effort read, + // which swallows config errors) so `db start` fails fast on a broken config. + yield* legacyCheckDbToml(fs, path, cliConfig.workdir); + + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to + // stderr and return nil (exit 0). + const running = yield* seam.isDbRunning(); + if (running) { + if (output.format === "text") { + yield* output.raw("Postgres database is already running.\n", "stderr"); + } else { + yield* output.success("Postgres database is already running.", { + status: "already-running", + }); + } + return; + } + + // Not running → bootstrap the container (StartDatabase + DockerRemoveAll on + // failure). The seam tees "Starting database...", "Initialising schema...", + // etc. to stderr. + // + // Resolve a relative `--from-backup` against the CALLER's cwd, mirroring Go's + // `StartDatabase` (`filepath.Join(utils.CurrentDirAbs, fromBackup)`, start.go:160-161) + // where `CurrentDirAbs` is captured before `ChangeWorkDir`. The seam spawns the Go child + // with cwd = the project workdir, so passing a relative path would resolve it against the + // project root (wrong file / not found) when `db start` runs from a subdirectory or with + // `--workdir`. Passing an absolute path makes the child's resolution a no-op. + const fromBackupFlag = Option.getOrUndefined(flags.fromBackup); + // An empty `--from-backup ""` is a normal no-backup start in Go (`len(fromBackup) == 0`), + // so treat it as absent rather than joining it to a directory path. + const fromBackup = + fromBackupFlag === undefined || fromBackupFlag === "" + ? undefined + : path.isAbsolute(fromBackupFlag) + ? fromBackupFlag + : path.join(runtimeInfo.cwd, fromBackupFlag); + yield* seam.startDatabase({ fromBackup }); + + if (output.format !== "text") { + yield* output.success("Started local database.", { status: "started" }); + } + }); + + // db start is local-only — no project ref, so no linked-project cache write. + // Telemetry still flushes on success and failure (Go's PersistentPostRun). + yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts new file mode 100644 index 0000000000..e53edc5f2b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -0,0 +1,286 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbBootstrapError } from "../shared/legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { legacyDbStart } from "./start.handler.ts"; +import type { LegacyDbStartFlags } from "./start.command.ts"; + +const DEFAULT_FLAGS: LegacyDbStartFlags = { fromBackup: Option.none() }; + +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `runningFails` / `startFails` make the respective + * call fail (Docker daemon down / StartDatabase error). `startExitCode`, when set, + * fails `startDatabase` with a `LegacyGoChildExitError` carrying that code instead — + * simulating the seam's real bootstrap child (`db __db-bootstrap --mode start`) + * exiting non-zero (CLI-1879). Records the args passed to `startDatabase`. + */ +function mockSeam( + opts: { + running?: boolean; + runningFails?: boolean; + startFails?: boolean; + startExitCode?: number; + } = {}, +) { + const startCalls: Array<{ fromBackup?: string }> = []; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => + opts.runningFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to inspect service" })) + : Effect.succeed(opts.running ?? false), + startDatabase: (args: { fromBackup?: string }) => { + if (opts.startExitCode !== undefined) { + return Effect.fail( + new LegacyGoChildExitError({ + exitCode: opts.startExitCode, + message: `failed to bootstrap the local database: exit ${opts.startExitCode}`, + }), + ); + } + return opts.startFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to bootstrap" })) + : Effect.sync(() => { + startCalls.push(args); + }); + }, + recreateDatabase: () => Effect.void, + awaitStorageReady: () => Effect.succeed(false), + }); + return { + layer, + get startCalls() { + return startCalls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + format?: OutputFormat; + running?: boolean; + runningFails?: boolean; + startFails?: boolean; + startExitCode?: number; + /** Caller cwd (Go's `CurrentDirAbs`) for relative `--from-backup` resolution. */ + cwd?: string; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + const out = mockOutput({ format: opts.format ?? "text" }); + const seam = mockSeam(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = Layer.mergeAll( + out.layer, + seam.layer, + mockLegacyCliConfig({ workdir }), + telemetry.layer, + mockRuntimeInfo({ cwd: opts.cwd ?? workdir }), + BunServices.layer, + ); + return { layer, out, seam, telemetry }; +} + +describe("legacy db start", () => { + const tmp = useLegacyTempWorkdir("supabase-db-start-"); + + it.live("reports an already-running database without starting a container", () => { + const { layer, out, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Postgres database is already running."); + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("starts the database when it is not running", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); + // db start prints no "Finished" line and no status table. + expect(out.stderrText).not.toContain("Finished"); + }); + }); + + it.live("forwards an absolute --from-backup to the bootstrap seam unchanged", () => { + const { layer, seam } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("/tmp/dump.sql") }).pipe( + Effect.provide(layer), + ); + expect(seam.startCalls).toEqual([{ fromBackup: "/tmp/dump.sql" }]); + }); + }); + + it.live("resolves a relative --from-backup against the caller cwd, not the workdir", () => { + // Go resolves a relative fromBackup against `CurrentDirAbs` (the caller cwd, captured + // before ChangeWorkDir), so the seam must receive the caller-relative absolute path even + // though its Go child runs with cwd = the project workdir. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + cwd: "/caller/here", + }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("dump.sql") }).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: "/caller/here/dump.sql" }]); + }); + }); + + it.live("treats an empty --from-backup as a normal no-backup start", () => { + // Go's StartDatabase sees `len(fromBackup) == 0` and starts without a backup; an empty + // string must not be joined to the caller cwd and passed as a directory path. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + cwd: "/caller/here", + }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("") }).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); + }); + }); + + it.live("proceeds with no config file (missing config is tolerated)", () => { + const { layer, seam } = setup(tmp.current, { running: false }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + }); + }); + + it.live("fails fast on a malformed config.toml", () => { + const { layer, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + // No container work attempted; telemetry still flushes on failure. + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("fails fast on an undecryptable secret even when the db is already running", () => { + // Regression for the seam swallowing config-load errors: Go runs `flags.LoadConfig` + // (which decrypts every secret) BEFORE `AssertSupabaseDbIsRunning`, so a broken + // config aborts `db start` regardless of container state. Previously the handler's + // only config read was the seam's best-effort one, so an undecryptable secret with + // the container already up printed "already running" and exited 0. + const { layer, out } = setup(tmp.current, { + toml: '[db]\nroot_key = "encrypted:anything"\n', + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse config: missing private key"); + } + expect(out.stderrText).not.toContain("already running"); + }); + }); + + it.live("propagates a Docker inspect failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', runningFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to inspect service"); + } + }); + }); + + it.live("propagates a StartDatabase failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', startFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to bootstrap"); + } + }); + }); + + it.live( + "propagates the bootstrap child's exact exit code as LegacyGoChildExitError and still flushes telemetry", + () => { + // The bootstrap seam's `startDatabase` (the `!captureStdout` bootstrap-child + // path) failing non-zero must reach the handler as the exact `LegacyGoChildExitError` + // it fails with — not a generic `LegacyDbBootstrapError` collapsing every exit code to + // 1 — and the handler's own `Effect.ensuring(telemetryState.flush)` finalizer must + // still run despite the typed failure (CLI-1879). + const { layer, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + startExitCode: 3, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(3); + } + expect(telemetry.flushed).toBe(true); + }); + }, + ); + + it.live("emits a json result when the database is already running", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("already-running"); + }); + }); + + it.live("emits a json result after starting the database", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("started"); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/start.layers.ts b/apps/cli/src/legacy/commands/db/start/start.layers.ts new file mode 100644 index 0000000000..71603292ee --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.layers.ts @@ -0,0 +1,27 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; + +/** + * Runtime layer for `supabase db start`. The command is local-only, so it needs + * far less than the remote-capable db commands: just the container-bootstrap seam + * (`db __db-bootstrap`), the CLI config (workdir + project id), and the telemetry + * flush. The seam's other dependencies (`LegacyNetworkIdFlag`, `LegacyProfileFlag`, + * `ChildProcessSpawner`, `FileSystem`, `Path`) are ambient from the root runtime, + * matching how `db diff` composes the `db __shadow` seam. `LegacyCliConfig` is + * provided to the seam explicitly (legacy CLAUDE.md rule 5). + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const seam = legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)); + +export const legacyDbStartRuntimeLayer = Layer.mergeAll( + seam, + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["db", "start"]), +); diff --git a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md index 90660186db..5dada1097d 100644 --- a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -6,10 +6,10 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ----------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | ## Files Written @@ -92,7 +92,7 @@ suppressed on stderr. `delete` ignores `-o`. - `--custom-hostname` is required for `create`. - `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. -- All subcommands resolve the ref via `--project-ref` → `PROJECT_ID` env → linked-project file, matching Go. +- All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → linked-project file, matching Go. - The project-ref fallback env var is `SUPABASE_PROJECT_ID`, matching Go (Go calls `viper.GetString("PROJECT_ID")` under `viper.SetEnvPrefix("SUPABASE")`, which resolves to the `SUPABASE_PROJECT_ID` environment variable). - **Documented divergences from Go (intentional):** - `--include-raw-output` is declared as a normal boolean **on each subcommand** (Go declares it as a persistent flag on the `domains` group). Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it cannot reproduce Cobra's help-hiding or the `Flag --include-raw-output has been deprecated` stderr warning, which Effect CLI has no hook for. It still reproduces the behavioral effect (forces `-o json` when `-o` is unset/pretty); on `delete` it is inert, matching Go. diff --git a/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md index 458b7aeb86..770358b237 100644 --- a/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md @@ -6,11 +6,11 @@ additionally reads the new key from stdin. ## Files Read -| Path | Format | When | -| ------------------------------------------------ | ------------------------- | ------------------------------------------------------------------ | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `~/.supabase//linked-project.json` | JSON | when `--project-ref` / `PROJECT_ID` unset, to resolve linked ref | -| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | +| Path | Format | When | +| ------------------------------------------------ | ------------------------- | ------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `~/.supabase//linked-project.json` | JSON | when `--project-ref` / `SUPABASE_PROJECT_ID` unset, to resolve linked ref | +| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | ## Files Written @@ -30,12 +30,11 @@ additionally reads the new key from stdin. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` / `PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (defaults to `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes @@ -76,7 +75,7 @@ One `result` event carrying `{root_key}` (both subcommands). ## Notes -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`/`PROJECT_ID`, or a linked project. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, or a linked project. - `update-root-key` reads the key from stdin: a real TTY is read with a masked prompt; piped stdin is decoded as UTF-8 and whitespace-trimmed. An empty or whitespace-only key sends an empty `root_key`, matching Go's `io.Copy` + diff --git a/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md index f3914b1b22..d46ffc8b86 100644 --- a/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md @@ -2,28 +2,39 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------------- | ---------- | ------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ----------------------------------------------- | ------ | ----------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------- | ------------ | ------------ | ---------------------- | -| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------- | ------------ | ------------ | ----------------------------------------------------- | +| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | ## Exit Codes @@ -34,6 +45,12 @@ | `1` | authentication error (no token found) | | `1` | network / connection 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` recorded verbatim, matching `functions list`; every other flag redacted) | + ## Output ### `--output-format text` (Go CLI compatible) diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.command.ts b/apps/cli/src/legacy/commands/functions/delete/delete.command.ts index c7fa7bec59..307d65aa3c 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.command.ts @@ -1,5 +1,6 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -17,6 +18,14 @@ const config = { export type LegacyFunctionsDeleteFlags = CliCommand.Command.Config.Infer; +// Exported so integration tests can drive the exact wiring `Command.withHandler` +// uses below, instead of re-asserting the generic instrumentation mechanism. +export const legacyFunctionsDeleteHandler = (flags: LegacyFunctionsDeleteFlags) => + legacyFunctionsDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), + withJsonErrorHandling, + ); + export const legacyFunctionsDeleteCommand = Command.make("delete", config).pipe( Command.withDescription( "Delete a Function from the linked Supabase project. This does NOT remove the Function locally.", @@ -32,11 +41,6 @@ export const legacyFunctionsDeleteCommand = Command.make("delete", config).pipe( description: "Delete a deployed function from a specific project", }, ]), - Command.withHandler((flags) => - legacyFunctionsDelete(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), - ), + Command.withHandler(legacyFunctionsDeleteHandler), Command.provide(legacyManagementApiRuntimeLayer(["functions", "delete"])), ); diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts index c3d78584d3..6c897b64db 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, Layer, Option, Stdio } from "effect"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { CurrentAnalyticsContext } from "../../../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../../../shared/telemetry/analytics.service.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -10,10 +13,34 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { legacyFunctionsDeleteHandler } from "./delete.command.ts"; import { legacyFunctionsDelete } from "./delete.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-functions-delete-legacy-"); +// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through +// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors +// the identical local helper in `legacy-command-instrumentation.unit.test.ts`. +// The shared `mockAnalytics()` in tests/helpers/mocks.ts deliberately doesn't +// merge this context (most callers don't need it). +function mockContextualAnalytics() { + const captured: Array<{ event: string; properties: Record }> = []; + const layer = Layer.succeed( + Analytics, + Analytics.of({ + capture: (event: string, properties: Record = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties: { ...context, ...properties } }); + }), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); + return { layer, captured }; +} + describe("legacy functions delete", () => { it.live("deletes a function natively through the Management API", () => { const out = mockOutput({ format: "text" }); @@ -68,4 +95,41 @@ describe("legacy functions delete", () => { expect(api.requests[0]?.url).toContain("/projects/qrstuvwxyzabcdefghij/functions/"); }).pipe(Effect.provide(layer)); }); + + it.live( + "does not redact --project-ref in cli_command_executed (Go parity: cmd/functions.go:153)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); + const analytics = mockContextualAnalytics(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + analytics, + }), + commandRuntimeLayer(["functions", "delete"]), + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "delete", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDeleteHandler({ + functionName: "hello-world", + projectRef: Option.some("abcdefghijklmnopqrst"), + }); + + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties.flags).toEqual({ "project-ref": "abcdefghijklmnopqrst" }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts index 495cb4f3bc..cb5577e056 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts @@ -1,5 +1,6 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -64,7 +65,7 @@ export const legacyFunctionsDeployCommand = Command.make("deploy", config).pipe( ]), Command.withHandler((flags) => legacyFunctionsDeploy(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts index 5f8df92de9..b9806a8151 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts @@ -4,21 +4,25 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; -// Argument-validation negatives for `functions deploy`. This validation lives in -// the Go CLI today (the legacy TS command proxies to it); a black-box subprocess -// test keeps these assertions valid through the eventual native TS port — it -// guards behavior, not implementation. Asserting the SPECIFIC error text also -// avoids a false pass from an unrelated non-zero exit (e.g. a missing Go binary). +// Argument-validation negatives for `functions deploy`. Both checks below are +// native TS today (deployFunctions in shared/functions/deploy.ts) — the +// bundler-mutex message byte-matches cobra's validateExclusiveFlagGroups +// template, and --jobs mirrors Go's own top-of-RunE guard (`if useApi { ... } +// else if maxJobs > 1 { error }`). A black-box subprocess test still earns its +// keep here: asserting the SPECIFIC error text avoids a false pass from an +// unrelated non-zero exit (e.g. a missing build artifact), and exercises the +// real CLI entrypoint end to end. // -// All cases fail before any network call (cobra flag parsing / pre-resolution), -// so no auth or linked project is required. +// All cases fail before any network call (flag-group validation / the jobs +// check both run before project-ref resolution), so no auth or linked +// project is required. const E2E_TIMEOUT_MS = 30_000; const SLUG = "deploy-e2e-basic"; // Valid-format token + ref to clear the auth and project-ref gates (both checked -// before the Go bundler-flag validation under test). These cases all fail before -// any network call (cobra flag-group validation / the jobs check at the top of -// RunE), so neither value is ever used against a real API. +// before the bundler-flag validation under test). These cases all fail before +// any network call (flag-group validation / the jobs check at the top of the +// handler), so neither value is ever used against a real API. const FAKE_TOKEN = `sbp_${"0".repeat(40)}`; const FAKE_REF = "a".repeat(20); @@ -41,7 +45,10 @@ describe("supabase functions deploy (legacy) — argument validation", () => { }, ); expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/none of the others can be|mutually exclusive/i); + // Byte-matches cobra's validateExclusiveFlagGroups (flag_groups.go:204). + expect(stderr).toContain( + "if any flags in the group [use-api use-docker legacy-bundle] are set none of the others can be", + ); }); } @@ -56,12 +63,36 @@ describe("supabase functions deploy (legacy) — argument validation", () => { }, ); expect(exitCode).not.toBe(0); - // The Go CLI phrases this as either "must be used together with --use-api" - // or "cannot be used with local bundling" depending on version — both mean - // --jobs is rejected without server-side (--use-api) bundling. - expect(stderr).toMatch(/--jobs\b.*(--use-api|local bundling)/i); + expect(stderr).toContain("--jobs must be used together with --use-api"); }); + test( + "rejects --jobs without --use-api even with --use-docker=false (Go parity gap)", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stderr } = await runSupabase( + [ + "functions", + "deploy", + SLUG, + "--project-ref", + FAKE_REF, + "--use-docker=false", + "--jobs", + "2", + ], + { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: FAKE_TOKEN }, + }, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("--jobs must be used together with --use-api"); + }, + ); + test("fails without a linked project or --project-ref", { timeout: E2E_TIMEOUT_MS }, async () => { using home = makeTempHome(); const workdir = mkdtempSync(join(tmpdir(), "fn-deploy-nolink-")); diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index ece00daaa3..f1f8e6c4c9 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -41,6 +41,7 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi projectRoot: cliConfig.workdir, supabaseDir: join(cliConfig.workdir, "supabase"), dashboardUrl: legacyDashboardUrl(cliConfig.profile), + goViperCompat: true, yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 7984803387..e572727916 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -14,6 +14,8 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; +import { ConflictingFunctionDeployFlagsError } from "../../../../shared/functions/deploy.errors.ts"; import { legacyFunctionsDeploy } from "./deploy.handler.ts"; import type { LegacyFunctionsDeployFlags } from "./deploy.command.ts"; @@ -482,4 +484,285 @@ describe("legacy functions deploy", () => { ), ); }); + + it.live("rejects the bundler mutex with cobra's exact error text", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api", "--use-docker"]), + }), + ); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDeploy({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(ConflictingFunctionDeployFlagsError); + if (!(error instanceof ConflictingFunctionDeployFlagsError)) { + throw new Error(`unexpected error: ${String(error)}`); + } + expect(error.message).toBe( + "if any flags in the group [use-api use-docker legacy-bundle] are set none of the others can be; [use-api use-docker] were all set", + ); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + describe("--jobs validation (Go parity: cmd/functions.go:79-82)", () => { + function setupJobsTest(rawArgs: ReadonlyArray) { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => Effect.succeed(legacyJsonResponse(request, 200, [])), + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ args: Effect.succeed(rawArgs) }), + ); + return { out, api, layer }; + } + + it.live("rejects --jobs > 1 without --use-api, even with default --use-docker", () => { + const { layer } = setupJobsTest(["functions", "deploy", "hello-world", "--jobs", "2"]); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: false, + useDocker: true, + jobs: Option.some(2), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("--jobs must be used together with --use-api"); + }); + }); + + it.live("rejects --jobs > 1 with --use-docker=false and no --use-api (Go parity gap)", () => { + // Divergence this test guards: previously the guard only fired when local + // bundling (Docker/legacy-bundle) was active, so `--use-docker=false --jobs 2` + // (no --use-api) silently passed in TS while Go rejected it. + const { layer } = setupJobsTest([ + "functions", + "deploy", + "hello-world", + "--use-docker=false", + "--jobs", + "2", + ]); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: false, + useDocker: false, + jobs: Option.some(2), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("--jobs must be used together with --use-api"); + }); + }); + + it.live("allows --jobs > 1 together with --use-api", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: "functions/hello-world/index.ts", + import_map_path: "functions/hello-world/deno.json", + }), + ); + }, + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api", "--jobs", "2"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: true, + jobs: Option.some(2), + }); + + expect(out.stdoutText).toContain( + "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + + it.live("treats --jobs 0 as 1 and does not require --use-api", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: "functions/hello-world/index.ts", + import_map_path: "functions/hello-world/deno.json", + }), + ); + }, + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--jobs", "0"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: false, + jobs: Option.some(0), + }); + + expect(out.stdoutText).toContain( + "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + }); + + describe("bundler routing with --use-api=false (Go parity: cmd/functions.go:79-80)", () => { + it.live("falls through to Docker bundling, not the API path, when --use-api=false", () => { + // Divergence this test guards: Go's `if useApi { useDocker = false }` only forces + // the API path when the RESOLVED value is true. `--use-api=false` alone must leave + // `useDocker`'s own value (default true) in effect, routing to Docker — previously + // `useLocalBundler` keyed off flag *presence* (`explicitUseApi`), so typing + // `--use-api=false` silently forced the API path instead. + const out = mockOutput({ format: "text" }); + const child = mockChildProcessSpawner({ exitCode: 1 }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: "functions/hello-world/index.ts", + import_map_path: "functions/hello-world/deno.json", + }), + ); + }, + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: false, + useDocker: true, + }); + + // Docker was actually attempted (proves useLocalBundler resolved to true); + // it wasn't running, so the command fell back to the API and still succeeded. + expect(child.spawned).toEqual([{ command: "docker", args: ["info"] }]); + expect(out.stderrText).toContain("WARNING: Docker is not running\n"); + expect(out.stdoutText).toContain( + "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index 7a70bcb517..9c9c6a1845 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -2,45 +2,72 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------------- | ---------- | ------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written -| Path | Format | When | -| --------------------------------------------------- | ------ | ---------------------------------------- | -| `/supabase/functions//` | bytes | for each source file returned by the API | +| Path | Format | When | +| --------------------------------------------------- | ------ | ----------------------------------------------------------------------- | +| `/supabase/functions//` | bytes | for each source file returned by the API | +| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------------------------ | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | -| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from metadata | -| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | multipart function source | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | ----------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | +| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from metadata | +| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | multipart function source | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Subprocesses -| Command | When | Purpose | -| ------------------------------------ | ----------------------------------- | ----------------------------------- | -| `supabase-go functions download ...` | `--use-docker` or `--legacy-bundle` | preserve hidden compatibility modes | +| Command | When | Purpose | +| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------- | +| `supabase-go functions download ...` | `--use-docker` (default) or `--legacy-bundle`, unless `--use-api` | preserve hidden compatibility modes | + +The delegated call runs with `SUPABASE_TELEMETRY_DISABLED=1` so the Go child's +own `cli_command_executed` doesn't double-count on top of this command's own +telemetry (mirrors `db pull`/`db diff`'s delegated-call pattern). In +`--output-format json|stream-json`, the child's stdout is captured and +discarded instead of inherited (`LegacyGoProxy.execCapture`) — the raw text +never reaches the terminal, and this command emits the `Output` envelope +itself once the child exits successfully. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------- | -| `0` | success | -| `1` | API error (non-2xx response) | -| `1` | authentication error (no token found) | -| `1` | network / connection failure | +| Code | Condition | +| ---- | -------------------------------------- | +| `0` | success | +| `1` | API error (non-2xx response) | +| `1` | authentication error (no token found) | +| `1` | network / connection failure | +| `1` | invalid function slug or flag conflict | + +## 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` recorded verbatim, matching `functions list`/`delete`; `use-api`/`use-docker`/`legacy-bundle` also recorded verbatim since they are boolean flags, matching Go's `isBooleanFlag` branch; no flag on this command is currently redacted) | ## Output @@ -50,11 +77,13 @@ Prints progress and success messages as functions are downloaded. ### `--output-format json` -Prints a structured success result with the downloaded function slugs and project ref. +Prints a structured success result with the downloaded function slugs and project ref. On the +Docker/legacy-bundle proxy path, the Go child's stdout is captured/discarded (never inherited) so +it can't corrupt the envelope; the slug list is resolved independently for the payload. ### `--output-format stream-json` -Prints a structured success result with the downloaded function slugs and project ref. +Same envelope as `json` above (including on the proxy path). ## Notes @@ -62,4 +91,7 @@ Prints a structured success result with the downloaded function slugs and projec - Requires a linked project (`--project-ref` or linked project config). - Native downloads reject path traversal and symlink escapes before writing source files. - `--use-docker` and `--legacy-bundle` are hidden flags forwarded to the Go binary for backward compatibility; they are mutually exclusive with `--use-api`. +- `--use-docker` defaults to `true` (Go parity), so a bare `supabase functions download` proxies to the Go binary's Docker-based unbundler unless `--use-api` resolves to `true`, which forces the native server-side download path instead (`apps/cli-go/cmd/functions.go:51-53`: `if useApi { useDocker = false }` reads the resolved flag value, not presence — `--use-api=false` still proxies). +- If Docker is not running, the Go binary itself prints `WARNING: Docker is not running` to stderr and falls back to its own server-side unbundler — the command still exits `0` without Docker installed or running. +- The mutual-exclusivity check only counts flags the user explicitly passed on the command line, not `--use-docker`'s default value — so `--use-api` alone never trips the "mutually exclusive" error. The Go proxy call itself also only ever forwards one of `--use-docker`/`--legacy-bundle`, never both, even though `--use-docker` defaults to `true`. - Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref. diff --git a/apps/cli/src/legacy/commands/functions/download/download.command.ts b/apps/cli/src/legacy/commands/functions/download/download.command.ts index 0d3f559dd0..e7cb3d5ad5 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.command.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.command.ts @@ -1,5 +1,6 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -19,6 +20,7 @@ const config = { ), useDocker: Flag.boolean("use-docker").pipe( Flag.withDescription("Use Docker to unbundle functions locally."), + Flag.withDefault(true), Flag.withHidden, ), legacyBundle: Flag.boolean("legacy-bundle").pipe( @@ -29,6 +31,14 @@ const config = { export type LegacyFunctionsDownloadFlags = CliCommand.Command.Config.Infer; +// Exported so integration tests can drive the exact wiring `Command.withHandler` +// uses below, instead of re-asserting the generic instrumentation mechanism. +export const legacyFunctionsDownloadHandler = (flags: LegacyFunctionsDownloadFlags) => + legacyFunctionsDownload(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), + withJsonErrorHandling, + ); + export const legacyFunctionsDownloadCommand = Command.make("download", config).pipe( Command.withDescription( "Download the source code for a Function from the linked Supabase project. If no function name is provided, downloads all functions.", @@ -44,11 +54,6 @@ export const legacyFunctionsDownloadCommand = Command.make("download", config).p description: "Download all functions from a specific project", }, ]), - Command.withHandler((flags) => - legacyFunctionsDownload(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), - ), + Command.withHandler(legacyFunctionsDownloadHandler), Command.provide(legacyManagementApiRuntimeLayer(["functions", "download"])), ); diff --git a/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts b/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts new file mode 100644 index 0000000000..f52a23d3be --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "vitest"; +import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; + +// Argument-validation negatives for `functions download`. This validation +// lives in the Go CLI today (the legacy TS command proxies to it); a +// black-box subprocess test keeps these assertions valid through the +// eventual native TS port — it guards behavior, not implementation. Mirrors +// `deploy.e2e.test.ts`'s coverage for the sibling `--use-docker` default bug +// (CLI-1862). +// +// The mutex-conflict cases below fail before any network call (flag parsing +// / mutex validation), so no auth or linked project is required. + +const E2E_TIMEOUT_MS = 30_000; +const SLUG = "download-e2e-basic"; +const FAKE_TOKEN = `sbp_${"0".repeat(40)}`; +const FAKE_REF = "a".repeat(20); + +describe("supabase functions download (legacy) — argument validation", () => { + const conflicts = [ + { name: "--use-api + --use-docker", flags: ["--use-api", "--use-docker"] }, + { name: "--use-api + --legacy-bundle", flags: ["--use-api", "--legacy-bundle"] }, + { name: "--use-docker + --legacy-bundle", flags: ["--use-docker", "--legacy-bundle"] }, + ] as const; + + for (const { name, flags } of conflicts) { + test(`rejects ${name} as mutually exclusive`, { timeout: E2E_TIMEOUT_MS }, async () => { + using home = makeTempHome(); + const { exitCode, stderr } = await runSupabase( + ["functions", "download", SLUG, "--project-ref", FAKE_REF, ...flags], + { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: FAKE_TOKEN }, + }, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/none of the others can be|mutually exclusive/i); + }); + } + + // CLI-1862: `--use-docker` now defaults to `true` (Go parity). Before the + // fix, that default was counted as "explicitly selected" by the mutex + // check, so passing `--use-api` alone was incorrectly rejected as + // conflicting with the (unpassed) `--use-docker` default. Covered in + // `download.integration.test.ts` ("does not treat the --use-docker default + // as conflicting with an explicit --use-api") via a mocked platform API + // instead of here: now that the mutex check is fixed, `--use-api` alone + // passes validation and proceeds to the native downloader, which calls the + // real Management API with the fake token/ref — an argument-validation + // test shouldn't depend on that network round-trip. + + // CLI-1862: the TS→Go proxy call must not forward the now-defaulted + // `--use-docker` alongside an explicit `--legacy-bundle` — the Go binary + // re-parses this argv itself and enforces the same mutual exclusivity, so + // forwarding both breaks `--legacy-bundle` outright. Covered in + // `download.integration.test.ts` ("forwards only --legacy-bundle to the Go + // proxy...") via a mocked `LegacyGoProxy` instead of here: unlike + // `--use-api`, `--legacy-bundle` routes to the Go binary's `RunLegacy` + // downloader, which calls `InstallOrUpgradeDeno` before any network call + // (`apps/cli-go/internal/functions/download/download.go`). Each e2e run + // gets a fresh `SUPABASE_HOME`, so this would trigger a real, uncached + // Deno download from GitHub on every run — a real cross-boundary + // dependency this suite shouldn't take on to prove a pure TS-side routing + // decision. +}); diff --git a/apps/cli/src/legacy/commands/functions/download/download.handler.ts b/apps/cli/src/legacy/commands/functions/download/download.handler.ts index 70cb9da73c..4b666da7f6 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Stdio } from "effect"; import { downloadFunctions, makeGoProxyDownloadArgs, @@ -20,11 +20,14 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const proxy = yield* LegacyGoProxy; + const stdio = yield* Stdio.Stdio; + const rawArgs = yield* stdio.args; let resolvedProjectRef = Option.none(); yield* downloadFunctions(flags, { api, projectRoot: cliConfig.workdir, + rawArgs, resolveProjectRef: (projectRef) => resolver.resolve(projectRef).pipe( Effect.tap((ref) => @@ -33,8 +36,23 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu }), ), ), - proxyDownload: (proxyFlags, projectRef) => - proxy.exec(makeGoProxyDownloadArgs(proxyFlags, projectRef)), + // The delegated Go binary runs its own `Execute()` and would otherwise + // fire its own `cli_command_executed` on top of this command's own + // `withLegacyCommandInstrumentation` wrapper. Suppress it so proxied + // invocations record exactly one event, matching Go (mirrors `db pull` / + // `db diff`'s delegated-call pattern). + // + // In machine-output mode the child's stdout is captured and discarded + // instead of inherited, matching `db pull`/`db diff`'s delegated-call + // pattern for the CLI-1546 "stdout is payload-only in machine mode" + // invariant — `downloadFunctions` emits the `Output` envelope itself. + proxyDownload: (proxyFlags, projectRef, captureOutput) => { + const args = makeGoProxyDownloadArgs(proxyFlags, projectRef); + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + return captureOutput + ? Effect.asVoid(proxy.execCapture(args, { env, stdin: "ignore" })) + : proxy.exec(args, { env }); + }, }).pipe( Effect.ensuring( Effect.suspend(() => diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index a6384d4e06..af37969828 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option, Stdio } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { CurrentAnalyticsContext } from "../../../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../../../shared/telemetry/analytics.service.ts"; import { buildLegacyTestRuntime, legacyJsonResponse, @@ -15,10 +18,35 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { ConflictingFunctionDownloadFlagsError } from "../../../../shared/functions/download.errors.ts"; +import { legacyFunctionsDownloadHandler } from "./download.command.ts"; import type { LegacyFunctionsDownloadFlags } from "./download.command.ts"; import { legacyFunctionsDownload } from "./download.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-functions-download-legacy-"); + +// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through +// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors +// the identical local helper in `legacy-command-instrumentation.unit.test.ts`. +// The shared `mockAnalytics()` in tests/helpers/mocks.ts deliberately doesn't +// merge this context (most callers don't need it). +function mockContextualAnalytics() { + const captured: Array<{ event: string; properties: Record }> = []; + const layer = Layer.succeed( + Analytics, + Analytics.of({ + capture: (event: string, properties: Record = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties: { ...context, ...properties } }); + }), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); + return { layer, captured }; +} const baseFlags: LegacyFunctionsDownloadFlags = { functionName: Option.some("hello-world"), projectRef: Option.none(), @@ -53,14 +81,26 @@ function multipartResponse(request: Parameters> = []; + const envs: Array | undefined> = []; + const captureCalls: Array> = []; + const captureEnvs: Array | undefined> = []; return { calls, + envs, + captureCalls, + captureEnvs, layer: Layer.succeed(LegacyGoProxy, { - exec: (args) => + exec: (args, opts) => Effect.sync(() => { calls.push([...args]); + envs.push(opts?.env); + }), + execCapture: (args, opts) => + Effect.sync(() => { + captureCalls.push([...args]); + captureEnvs.push(opts?.env); + return ""; }), - execCapture: () => Effect.succeed(""), }), }; } @@ -86,6 +126,15 @@ describe("legacy functions download", () => { telemetry: telemetry.layer, }), proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), ); return Effect.gen(function* () { @@ -108,7 +157,7 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); - it.live("keeps hidden Docker compatibility mode behind the Go proxy", () => { + it.live("proxies to Docker by default (Go parity), with no flags passed", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); const proxy = mockProxy(); @@ -119,9 +168,20 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), ); return Effect.gen(function* () { + // `useDocker: true` mirrors what the CLI parser now resolves to by + // default (CLI-1862) — no `--use-docker` flag appears in argv above. yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); expect(api.requests).toEqual([]); @@ -135,6 +195,473 @@ describe("legacy functions download", () => { "--use-docker", ], ]); + // The delegated Go binary must not also fire its own + // `cli_command_executed` on top of this command's own instrumentation. + expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "does not treat the --use-docker default as conflicting with an explicit --use-api", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request)) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-api", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + // The CLI parser resolves `useDocker: true` here too (its default), + // even though only `--use-api` was passed explicitly. Neither the + // mutex check nor the routing decision should treat that default as + // if the user had asked for Docker. + yield* legacyFunctionsDownload({ ...baseFlags, useApi: true, useDocker: true }); + + expect(proxy.calls).toEqual([]); + expect( + yield* Effect.tryPromise(() => + readFile( + join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), + "utf8", + ), + ), + ).toBe("console.log('legacy native')"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("still proxies to Docker when --use-api=false is passed explicitly", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-api=false", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + // Go's override is value-based (`if useApi { useDocker = false }`, + // apps/cli-go/cmd/functions.go:51-53), not presence-based. An explicit + // `--use-api=false` must not be treated like `--use-api` — it should + // leave the `--use-docker` default (true) in effect and still proxy. + yield* legacyFunctionsDownload({ ...baseFlags, useApi: false, useDocker: true }); + + expect(api.requests).toEqual([]); + expect(proxy.calls).toEqual([ + [ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + "--use-docker", + ], + ]); + expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success envelope when proxying to Docker in machine-output mode", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + // CLI-1546: stdout is payload-only in machine mode, so the Go child's + // raw output must be captured/discarded (not inherited) and this + // command must emit the `Output` envelope itself, matching the native + // path's shape. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([ + [ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + "--use-docker", + ], + ]); + expect(proxy.captureEnvs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { function_slugs: ["hello-world"], project_ref: "abcdefghijklmnopqrst" }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "lists remote functions before delegating when no function name is given in machine mode", + () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed( + legacyJsonResponse(request, 200, [ + { slug: "hello-world" }, + { slug: "goodbye-world" }, + ]), + ) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([ + ["functions", "download", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], + ]); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { + function_slugs: ["hello-world", "goodbye-world"], + project_ref: "abcdefghijklmnopqrst", + }, + }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "reports no functions found without delegating when the project is empty in machine mode", + () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed(legacyJsonResponse(request, 200, [])) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + // An empty project has nothing to delegate — this must match the + // native path's "No functions found." short-circuit instead of + // still invoking the Go/Docker child and reporting a misleading + // "Downloaded Edge Function source." success with an empty list. + yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "No functions found.", + data: { function_slugs: [], project_ref: "abcdefghijklmnopqrst" }, + }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails before delegating when the pre-flight function list fails in machine mode", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed(legacyJsonResponse(request, 500, { message: "unavailable" })) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + // The pre-flight list failure must be reported before any download + // side effect — the delegated proxy must never be invoked (CLI-1862 + // review: a listing failure after a successful delegated download + // must not mask that success). + const exit = yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards only --legacy-bundle to the Go proxy, not the --use-docker default too", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--legacy-bundle", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + // `useDocker: true` mirrors the CLI parser's default (CLI-1862) even + // though only `--legacy-bundle` was passed explicitly. The Go proxy + // call must not forward both, or the Go binary's own + // MarkFlagsMutuallyExclusive rejects the combination. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true, legacyBundle: true }); + + expect(proxy.calls).toEqual([ + [ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + "--legacy-bundle", + ], + ]); + expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects an invalid slug before ever reaching the Go proxy", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "../../etc", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + // `useDocker: true` is the real default (CLI-1862). Before this fix, + // slug validation only ran on the native path, so a malformed slug + // would sail past it and straight into the Go proxy argv. + const exit = yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.some("../../etc"), + useDocker: true, + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(proxy.calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "does not redact --project-ref in cli_command_executed (Go parity: cmd/functions.go:178)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request)) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const analytics = mockContextualAnalytics(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + analytics, + }), + proxy.layer, + commandRuntimeLayer(["functions", "download"]), + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownloadHandler({ + ...baseFlags, + projectRef: Option.some("abcdefghijklmnopqrst"), + }); + + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties.flags).toEqual({ "project-ref": "abcdefghijklmnopqrst" }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("rejects the bundler mutex with cobra's exact error text", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "download", "--use-api", "--use-docker"]), + }), + ); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDownload({ + ...baseFlags, + useApi: true, + useDocker: true, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ConflictingFunctionDownloadFlagsError); + if (!(error instanceof ConflictingFunctionDownloadFlagsError)) { + throw new Error(`unexpected error: ${String(error)}`); + } + expect(error.message).toBe( + "if any flags in the group [use-api use-docker legacy-bundle] are set none of the others can be; [use-api use-docker] were all set", + ); + expect(proxy.calls).toEqual([]); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md index b6764b7dfc..f1613f83fe 100644 --- a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md @@ -34,7 +34,6 @@ | `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | | `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> current working dir) | -| ~~`SUPABASE_API_URL`~~ | **not honored** - Go parity. Use `SUPABASE_PROFILE` instead. | - | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/functions/list/list.command.ts b/apps/cli/src/legacy/commands/functions/list/list.command.ts index f411518741..4f85ebeb0b 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.command.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -29,7 +30,7 @@ export const legacyFunctionsListCommand = Command.make("list", config).pipe( ]), Command.withHandler((flags) => legacyFunctionsList(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/functions/new/new.command.ts b/apps/cli/src/legacy/commands/functions/new/new.command.ts index 8a161adf28..5a9ef84b25 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.command.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.command.ts @@ -36,7 +36,7 @@ export const legacyFunctionsNewCommand = Command.make("new", config).pipe( Command.withShortDescription("Create a new Function locally"), Command.withHandler((flags) => legacyFunctionsNew(flags).pipe( - withLegacyCommandInstrumentation({ flags }), + withLegacyCommandInstrumentation({ flags, config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index b448c0307a..5080e4416b 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -89,7 +89,9 @@ const listExistingFunctionSlugs = Effect.fnUntraced(function* (workdir: string) }); const resolveTemplateInputs = Effect.fnUntraced(function* (workdir: string, slug: string) { - const loaded = yield* loadProjectConfig(workdir).pipe(Effect.orElseSucceed(() => null)); + const loaded = yield* loadProjectConfig(workdir, { goViperCompat: true }).pipe( + Effect.orElseSucceed(() => null), + ); const port = loaded?.config.api.port ?? DEFAULT_LOCAL_API_PORT; const publishableKey = loaded?.config.auth.publishable_key ?? defaultPublishableKey; return { diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts index 1e3cd4ea5e..83992053b6 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts @@ -54,7 +54,7 @@ export const legacyFunctionsServeCommand = Command.make("serve", config).pipe( Command.withShortDescription("Serve all Functions locally"), Command.withHandler((flags) => legacyFunctionsServe(flags).pipe( - withLegacyCommandInstrumentation({ flags }), + withLegacyCommandInstrumentation({ flags, config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 0718862e64..86b51f387b 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -33,5 +33,6 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function debug, networkId, projectIdOverride: cliConfig.projectId, + goViperCompat: true, }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 370af5aacf..5b8d75be10 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -51,11 +51,15 @@ const deployMockState = vi.hoisted(() => ({ command: string, args: ReadonlyArray, options: unknown, - ) => { - exitCode: number; - stdout: string; - stderr: string; - }), + ) => + | { + exitCode: number; + stdout: string; + stderr: string; + } + // Never resolves — lets a test fork+interrupt while this specific call is in flight, + // matching Effect's own canonical "forever pending, interruptible" primitive. + | { pending: true }), reset() { this.isDockerRunning = true; this.runCalls = []; @@ -83,7 +87,7 @@ vi.mock("../../../../shared/functions/deploy.ts", async () => { deployMockState.volumeCalls.push({ volumeName, projectId }); }), runChildProcess: (command: string, args: ReadonlyArray, options?: unknown) => - Effect.sync(() => { + Effect.suspend(() => { const envFile = args.flatMap((value, index) => args[index - 1] === "--env-file" ? [value] : [], )[0]; @@ -117,13 +121,12 @@ vi.mock("../../../../shared/functions/deploy.ts", async () => { }), }; deployMockState.runCalls.push({ command, args: [...args], options: enrichedOptions }); - return ( - deployMockState.runHandler?.(command, args, options) ?? { - exitCode: 0, - stdout: "", - stderr: "", - } - ); + const result = deployMockState.runHandler?.(command, args, options) ?? { + exitCode: 0, + stdout: "", + stderr: "", + }; + return "pending" in result ? Effect.never : Effect.succeed(result); }), }; }); @@ -508,6 +511,19 @@ describe("legacy functions serve integration", () => { }, }); + expect(deployMockState.runCalls).toContainEqual({ + command: "docker", + args: [ + "exec", + "supabase_kong_test-project", + "kong", + "reload", + "--nginx-conf", + "/home/kong/custom_nginx.template", + ], + options: { stdout: "ignore", stderr: "pipe" }, + }); + expect(childSpawner.spawned).toEqual([ { command: "docker", @@ -637,6 +653,87 @@ describe("legacy functions serve integration", () => { }); }); + it.live( + "cleans up a stale multiline-env directory from a previous run even when this run has no multiline secrets", + () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([ + { + exitCode: 1, + stderr: "error running container: exit 1", + }, + ]); + + const staleMultilineEnvDir = join( + tempRoot.current, + "supabase", + ".temp", + "start-secrets", + "supabase_edge_runtime_test-project", + "multiline-env", + ); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => + writeProjectFile(join("supabase", "functions", ".env"), ["HELLO=WORLD", ""].join("\n")), + ); + // Simulate a stale directory left behind by an earlier run that DID have multiline secrets. + yield* Effect.promise(async () => { + await mkdir(join(staleMultilineEnvDir, "values"), { recursive: true, mode: 0o700 }); + await writeFile(join(staleMultilineEnvDir, "multiline-env.sh"), "stale script\n"); + await writeFile(join(staleMultilineEnvDir, "values", "env-0"), "stale secret\n"); + }); + + const { layer } = setupServe({ childSpawner }); + + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + expect(error).toBeInstanceOf(Error); + + expect(existsSync(staleMultilineEnvDir)).toBe(false); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + expect( + extractFlagValues(dockerRun.args, "-v").some((value) => + value.endsWith(":/root/.supabase/multiline-env:ro"), + ), + ).toBe(false); + }); + }, + ); + it.live("fails before startup when a multiline env name is not a shell identifier", () => { return Effect.gen(function* () { yield* Effect.promise(() => @@ -1024,6 +1121,19 @@ describe("legacy functions serve integration", () => { ), ).toHaveLength(2); expect(out.stderrText).toContain("File change detected:"); + + // `functions serve`'s restart wrapper (`startEdgeRuntime`, Go's + // `restartEdgeRuntime`) reloads Kong after each successful bring-up — + // once for the initial start, once for the file-change-triggered restart. + expect( + deployMockState.runCalls.filter( + (call) => + call.command === "docker" && + call.args[0] === "exec" && + call.args.includes("supabase_kong_test-project") && + call.args.includes("reload"), + ), + ).toHaveLength(2); }); }); @@ -1143,6 +1253,84 @@ describe("legacy functions serve integration", () => { }); }); + it.live( + "cleans up staged secrets when interrupted while reloading Kong after a successful bring-up", + () => { + const processControl = mockQueuedProcessControl(); + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + // Hangs Kong reload so the interrupt below lands after bring-up succeeded (staged + // secrets already written, `startedRuntime` already assigned) but before this + // wrapper's own `reloadKong` call returns. + return { pending: true }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ pending: true }]); + + const stagingDir = join( + tempRoot.current, + "supabase", + ".temp", + "start-secrets", + "supabase_edge_runtime_test-project", + ); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ processControl, childSpawner }); + const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* waitFor( + () => + deployMockState.runCalls.some( + (call) => call.command === "docker" && call.args[0] === "exec", + ), + "timed out waiting for Kong reload to start", + ); + expect(existsSync(stagingDir)).toBe(true); + processControl.signal("SIGINT"); + + const exit = yield* Fiber.await(fiber); + expect(Exit.isSuccess(exit)).toBe(true); + + expect( + deployMockState.runCalls.some( + (call) => + call.command === "docker" && + call.args[0] === "container" && + call.args[1] === "rm" && + call.args.includes("supabase_edge_runtime_test-project"), + ), + ).toBe(true); + expect(existsSync(stagingDir)).toBe(false); + }); + }, + ); + it.live("passes inspect, debug, and custom network settings through to edge-runtime", () => { deployMockState.runHandler = (command, args) => { if (command !== "docker") { @@ -1518,6 +1706,98 @@ describe("legacy functions serve integration", () => { }, ); + it.live( + "does not fail startup on a malformed third-party provider config when auth is disabled", + () => { + // Go's `Auth.ThirdParty.validate()` (the "required field" check) only runs inside + // `Config.Validate`'s `if Auth.Enabled` block — `functions serve`'s own JWKS resolution + // discards `ResolveJWKS`'s error unconditionally, regardless of `auth.enabled`. So a + // workos provider enabled without an `issuer_url` must not block startup here. + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); + + return Effect.gen(function* () { + const fetchMock = vi.spyOn(globalThis, "fetch"); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + fetchMock.mockRestore(); + }), + ); + + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth]", + "enabled = false", + "", + "[auth.third_party.workos]", + "enabled = true", + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("jwks logs failed"); + } + expect(fetchMock).not.toHaveBeenCalled(); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + + const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); + expect(jwks).toBeDefined(); + if (jwks === undefined) { + throw new Error("missing SUPABASE_JWKS"); + } + expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ + keys: expect.arrayContaining([ + expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), + expect.objectContaining({ kty: "oct" }), + ]), + }); + }); + }, + ); + it.live("includes config-defined edge runtime secrets in the runtime env", () => { deployMockState.runHandler = (command, args) => { if (command !== "docker") { @@ -1695,7 +1975,7 @@ describe("legacy functions serve integration", () => { "verify_jwt = true", "", "[remotes.override]", - 'project_id = "override-project"', + 'project_id = "overrideprojectaaaaa"', "", "[remotes.override.functions.hello]", "verify_jwt = false", @@ -1710,7 +1990,7 @@ describe("legacy functions serve integration", () => { const { layer } = setupServe({ childSpawner, - projectId: Option.some("override-project"), + projectId: Option.some("overrideprojectaaaaa"), }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), @@ -1724,20 +2004,20 @@ describe("legacy functions serve integration", () => { expect(deployMockState.volumeCalls).toEqual([ { - volumeName: "supabase_edge_runtime_override-project", - projectId: "override-project", + volumeName: "supabase_edge_runtime_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.networkCalls).toEqual([ { - networkMode: "supabase_network_override-project", - projectId: "override-project", + networkMode: "supabase_network_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.runCalls).toContainEqual( expect.objectContaining({ command: "docker", - args: ["container", "inspect", "supabase_db_override-project"], + args: ["container", "inspect", "supabase_db_overrideprojectaaaaa"], }), ); diff --git a/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md index 325e8d58ed..00f28f8924 100644 --- a/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md @@ -20,10 +20,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md index 8cf887643d..f7dab774da 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------ | -| `supabase/config.toml` / `supabase/config.json` | TOML / JSON | always when present in the active workdir; used to discover `auth.signing_keys_path` | -| `` | JSON array of JWKs | when `auth.signing_keys_path` is configured; loaded before overwrite or append | -| git ignore rules | git metadata / ignore files | best-effort after a successful write when the resulting file contains exactly 1 key | +| Path | Format | When | +| ----------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------- | +| `supabase/config.toml` / `supabase/config.json` | TOML / JSON | always when present in the active workdir; used to discover `auth.signing_keys_path` | +| `` | JSON array of JWKs | when `auth.signing_keys_path` is configured; loaded before overwrite or append | +| git ignore rules | git metadata / ignore files | best-effort after a successful write when the resulting file contains exactly 1 key | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `flags.LoadConfig`/`loadNestedEnv`) | ## Files Written @@ -22,9 +23,9 @@ ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| - | - | - | +| Variable | Purpose | Required? | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | +| `SUPABASE_YES` | Auto-confirms the overwrite prompt, same as `--yes` (Go's `viper.GetBool("YES")`). Read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878). | No | ## Exit Codes @@ -47,16 +48,17 @@ ### `--output-format json` -Not applicable; the command uses raw stdout and stderr text like the Go CLI. +Not applicable to output rendering; the command uses raw stdout and stderr text like the Go CLI. It does, however, affect the overwrite-confirmation prompt: since this command has no structured json/stream-json payload, requesting a non-text format from a real interactive terminal (no `--yes`, no piped stdin) fails the overwrite closed (`context canceled`) rather than silently defaulting to yes on a destructive, irreversible action. A non-TTY caller (piped or not) is unaffected — piped `y`/`n` answers are honored regardless of `--output-format`. ### `--output-format stream-json` -Not applicable; the command uses raw stdout and stderr text like the Go CLI. +Same as `--output-format json` above. ## Notes - `--algorithm` accepts `ES256` (default, recommended) or `RS256`. - `--append` appends the new key to an existing keys file instead of overwriting. +- The overwrite prompt honors `SUPABASE_YES` (shell env or the project `.env`/`.env.local`/`.env.[.local]` files, shell wins) and an explicit `--yes=false` override, matching Go's `viper.GetBool("YES")` precedence (flag wins over env; an omitted flag falls back to the env var, resolved after `flags.LoadConfig` loads the project env — CLI-1878). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`), matching Go's `Console.ReadLine`/`PromptYesNo` — a piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) also falls back to the default. - `auth.signing_keys_path` is resolved relative to the active `supabase/config.toml` or `supabase/config.json`. - Generated keys are JWKs, not PEM files. - No network or Management API calls are involved. diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts index 35a026f682..83bc33b38d 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts @@ -3,6 +3,7 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -29,6 +30,10 @@ const legacyGenSigningKeyRuntimeLayer = Layer.mergeAll( cliConfig, legacyTelemetryStateLayer, commandRuntimeLayer(["gen", "signing-key"]), + // The overwrite-confirmation prompt reads piped stdin via `legacyPromptYesNo` + // (`stdin.readLine`), same as `config push`, `seed buckets`, `storage rm`, `db pull`, + // and `logout` — all of which merge `stdinLayer` alongside their runtime layer. + stdinLayer, ); export const legacyGenSigningKeyCommand = Command.make("signing-key", config).pipe( @@ -55,7 +60,7 @@ export const legacyGenSigningKeyCommand = Command.make("signing-key", config).pi ]), Command.withHandler((flags) => legacyGenSigningKey(flags).pipe( - withLegacyCommandInstrumentation({ flags }), + withLegacyCommandInstrumentation({ flags, config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts new file mode 100644 index 0000000000..3a2b377ee4 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts @@ -0,0 +1,63 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +/** + * Golden-path e2e for CLI-1865: exercises the real compiled-binary boundary — + * `signing-key.command.ts`'s actual production runtime layer, not the mocked + * `Stdin` the integration suite provides via `Layer.succeed`. A missing + * `stdinLayer` in that composition only surfaces as a "Service not found" defect + * at this boundary (see the legacy CLAUDE.md Go Parity Checklist item 5). Per-branch + * prompt/format coverage lives in the integration suite. + */ +describe("supabase gen signing-key (legacy)", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-gen-signing-key-e2e-")); + mkdirSync(join(projectDir, "supabase"), { recursive: true }); + writeFileSync( + join(projectDir, "supabase", "config.toml"), + '[auth]\nsigning_keys_path = "./signing_keys.json"\n', + ); + writeFileSync(join(projectDir, "supabase", "signing_keys.json"), "[]\n"); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test( + "declines the overwrite on a piped 'n' without crashing or writing the file", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], { + entrypoint: "legacy", + cwd: projectDir, + stdin: "n\n", + }); + expect(exitCode).toBe(1); + expect(stderr).toContain("context canceled"); + expect(stderr).not.toContain("Service not found"); + const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); + expect(JSON.parse(saved)).toEqual([]); + }, + ); + + test("overwrites on a piped 'y'", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], { + entrypoint: "legacy", + cwd: projectDir, + stdin: "y\n", + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("JWT signing key appended to:"); + const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); + expect(JSON.parse(saved)).toHaveLength(1); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 6396c658c6..87a734da73 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -6,9 +6,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { findGitRootPath } from "../../../../shared/git/git-root.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts"; @@ -161,7 +163,7 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const path = yield* Path.Path; - const loaded = yield* loadProjectConfig(cwd).pipe( + const loaded = yield* loadProjectConfig(cwd, { goViperCompat: true }).pipe( Effect.catchTag("ProjectConfigParseError", (cause) => Effect.fail( new LegacyGenSigningKeyConfigParseError({ @@ -246,25 +248,6 @@ const isGitIgnored = Effect.fnUntraced(function* (filePath: string, searchFrom: .pipe(Effect.map((exitCode) => Option.some(Number(exitCode) === 0))); }); -const confirmOverwrite = Effect.fnUntraced(function* (title: string) { - const output = yield* Output; - const tty = yield* Tty; - const yes = yield* LegacyYesFlag; - if (yes) { - yield* output.raw(`${title} [Y/n] y\n`, "stderr"); - return true; - } - if (!tty.stdinIsTty) { - yield* output.raw(`${title} [Y/n] \n`, "stderr"); - return true; - } - // In json / stream-json mode `promptConfirm` fails with NonInteractiveError; treat that as a - // declined overwrite so the command cancels cleanly instead of corrupting the machine payload. - return yield* output - .promptConfirm(title, { defaultValue: true }) - .pipe(Effect.orElseSucceed(() => false)); -}); - export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* ( flags: LegacyGenSigningKeyFlags, ) { @@ -279,6 +262,15 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* const warnText = (text: string) => styleIfTty(tty.stdoutIsTty, "yellow", text); return yield* Effect.gen(function* () { + // Go's `flags.LoadConfig` (`signingkeys.go:99`) loads the project `.env` files before the + // overwrite prompt reads `viper.GetBool("YES")` (`console.PromptYesNo`, `signingkeys.go:130`), + // so a `SUPABASE_YES` set only in `supabase/.env` must auto-confirm here too. Resolved inside + // this block (not above it) so a malformed/unreadable `.env` still flushes telemetry below, + // matching Go: telemetry attaches in root's `PersistentPreRunE` (`cmd/root.go:131-155`) + // before this command's own `RunE` runs `flags.LoadConfig`, so `service.Capture` still fires + // in Go even when that load fails. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); // Match Go's order: LoadConfig validates the configured signing-keys file before any key is // generated, so a broken config fails fast without doing throwaway crypto work. const signingKeysConfig = yield* loadSigningKeysConfig(cliConfig.workdir); @@ -298,9 +290,30 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* const nextKeys = flags.append ? [...configured.value.existingKeys, key] : yield* Effect.gen(function* () { - const confirmed = yield* confirmOverwrite( - `Do you want to overwrite the existing ${emphasize(configured.value.displayPath)} file?`, - ); + // `legacyPromptYesNo` silently returns the default (true) for any non-text + // `--output-format`, but this command has no structured json/stream-json output + // (SIDE_EFFECTS.md) — that combination only arises from a real interactive TTY + // explicitly requesting machine output. Fail closed rather than silently + // overwriting irrecoverable key material. + const confirmed = + !yes && tty.stdinIsTty && output.format !== "text" + ? false + : yield* legacyPromptYesNo( + // `legacyPromptYesNo` checks `output.format !== "text"` BEFORE it checks + // TTY, so a non-TTY (piped or empty) invocation under `json`/`stream-json` + // would otherwise hit that check first and return the default without + // ever reading stdin. Go's `console.PromptYesNo` + // (apps/cli-go/internal/utils/console.go:64-82) has no concept of output + // format at all — it always reads piped stdin — so a piped `y`/`n` answer + // must be honored here the same as in text mode. Present a text-shaped + // view of `output` to reach that read; `raw`/`promptConfirm` write the + // prompt to stderr under every `Output` layer, so this never touches the + // machine-readable stdout payload. + output.format === "text" ? output : { ...output, format: "text" }, + yes, + `Do you want to overwrite the existing ${emphasize(configured.value.displayPath)} file?`, + true, + ); if (!confirmed) { return yield* Effect.fail( new LegacyGenSigningKeyCancelledError({ message: "context canceled" }), diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index 93ac85949d..95439473a8 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts @@ -10,6 +10,7 @@ import { mockAnalytics, mockOutput, mockRuntimeInfo, + mockStdin, mockTty, processEnvLayer, } from "../../../../../tests/helpers/mocks.ts"; @@ -20,6 +21,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; @@ -32,6 +34,7 @@ import { legacyGenSigningKey } from "./signing-key.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-gen-signing-key-int-"); interface SetupOptions { + readonly format?: "text" | "json" | "stream-json"; readonly stdinIsTty?: boolean; readonly yes?: boolean; readonly promptConfirmResponses?: ReadonlyArray; @@ -39,6 +42,10 @@ interface SetupOptions { // Exit code returned by the mocked `git check-ignore` subprocess. `0` means the path is // ignored, any non-zero code means it is not. Only consumed by the gitignore-warning branch. readonly gitCheckIgnoreExitCode?: number; + // Piped (non-TTY) stdin answer for the overwrite prompt (CLI-1865). + readonly pipedAnswer?: string; + // Raw argv for `legacyResolveYes`'s explicit `--yes=false` detection. + readonly cliArgs?: ReadonlyArray; } // `git check-ignore` is invoked via ChildProcessSpawner. Mock it with a controlled exit code so @@ -68,7 +75,7 @@ function mockGitCheckIgnore(exitCode: number) { function setup(options: SetupOptions = {}) { const out = mockOutput({ - format: "text", + format: options.format ?? "text", interactive: options.stdinIsTty ?? false, promptConfirmResponses: options.promptConfirmResponses, }); @@ -82,6 +89,8 @@ function setup(options: SetupOptions = {}) { const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, api, cliConfig, tty, telemetry: telemetry?.layer }), Layer.succeed(LegacyYesFlag, options.yes ?? false), + Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), + mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), Layer.succeed(LegacyDebugLogger, { debug: () => Effect.void, http: () => Effect.void, @@ -156,6 +165,8 @@ describe("legacy gen signing-key integration", () => { processEnvLayer({ SUPABASE_HOME: tempRoot.current }), mockRuntimeInfo({ cwd: tempRoot.current, homeDir: tempRoot.current }), mockTty({ stdinIsTty: false, stdoutIsTty: false }), + Layer.succeed(CliArgs, { args: [] }), + mockStdin(false), Layer.succeed( TelemetryRuntime, TelemetryRuntime.of({ @@ -203,8 +214,38 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("overwrites the configured signing keys file and defaults to yes on non-tty", () => { - const { layer, out } = setup({ stdinIsTty: false }); + it.live( + "overwrites the configured signing keys file and defaults to yes on non-tty when stdin has no piped answer", + () => { + const { layer, out } = setup({ stdinIsTty: false }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + expect(parsed[0]?.alg).toBe("RS256"); + expect(out.stderrText).toContain("Do you want to overwrite the existing"); + expect(out.stderrText).toContain("JWT signing key appended to: "); + expect(out.stderrText).toContain(join("supabase", "signing_keys.json")); + }).pipe(Effect.provide(layer)); + }, + ); + + // CLI-1865: Go's overwrite prompt reads piped stdin even in non-TTY mode and honors an + // explicit "n" — before this fix, TS returned `true` unconditionally without reading stdin at + // all, so `echo n | supabase gen signing-key` silently overwrote instead of canceling. + it.live("cancels the overwrite when a piped non-tty answer of 'n' is read", () => { + const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "n" }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), @@ -213,17 +254,41 @@ describe("legacy gen signing-key integration", () => { writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), ); - yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenSigningKeyCancelledError"); + expect(json).toContain("context canceled"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + // Go's non-TTY prompt echoes the piped answer back to stderr after the label. + expect(out.stderrText).toContain("[Y/n] n\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("overwrites when a piped non-tty answer of 'y' is read", () => { + const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "y" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); const saved = yield* Effect.tryPromise(() => readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); const parsed = JSON.parse(saved) as ReadonlyArray>; expect(parsed).toHaveLength(1); - expect(parsed[0]?.alg).toBe("RS256"); expect(out.stderrText).toContain("Do you want to overwrite the existing"); - expect(out.stderrText).toContain("JWT signing key appended to: "); - expect(out.stderrText).toContain(join("supabase", "signing_keys.json")); }).pipe(Effect.provide(layer)); }); @@ -440,6 +505,199 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); + // This command has no structured json/stream-json output (SIDE_EFFECTS.md), so a real TTY + // requesting machine output is an unsupported combination — fail closed on this destructive, + // irreversible overwrite rather than silently defaulting to yes with no prompt at all. + it.live( + "declines the overwrite without prompting on a tty when --output-format is not text", + () => { + const { layer, out } = setup({ format: "json", stdinIsTty: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + expect(out.promptConfirmCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + // CLI-1865 follow-up: `legacyPromptYesNo` checks `output.format !== "text"` BEFORE it + // checks TTY, so a non-TTY invocation under `json`/`stream-json` must not fall into that + // early return — this command has no structured json/stream-json payload, so a piped + // answer must be honored the same as text mode. Before this fix, a piped "n" here was + // silently ignored and the file was overwritten with the default (true). + it.live("honors a piped non-tty 'n' even when --output-format is json", () => { + const { layer, out } = setup({ format: "json", stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + expect(out.promptConfirmCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors a piped non-tty 'y' when --output-format is stream-json", () => { + const { layer } = setup({ format: "stream-json", stdinIsTty: false, pipedAnswer: "y" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved) as ReadonlyArray).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_YES and overwrites even when a piped 'n' is present", () => { + // Go reads `viper.GetBool("YES")` (incl. the SUPABASE_YES env var) BEFORE scanning + // stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase gen signing-key` + // auto-confirms and overwrites rather than consuming the piped `n`. The handler + // resolves `yes` via `legacyResolveYes`, not the raw --yes flag. + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + + it.live( + "auto-confirms from SUPABASE_YES in the project .env, even with a piped 'n' (CLI-1878)", + () => { + // SUPABASE_YES lives only in supabase/.env, not the shell. Go's `flags.LoadConfig` + // (`signingkeys.go:99`) loads the project `.env` files before the overwrite prompt reads + // `viper.GetBool("YES")` (`signingkeys.go:130`), so the overwrite auto-confirms and the + // piped `n` is never consumed — same precedence as the shell-env case above. + // + // Defensively clear a shell SUPABASE_YES: this test must prove the project-.env source + // specifically, not accidentally pass because a prior test in this file left the shell + // env set (the sibling shell-env tests above save/restore theirs). + const prev = process.env["SUPABASE_YES"]; + delete process.env["SUPABASE_YES"]; + const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", ".env"), "SUPABASE_YES=true\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev !== undefined) process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }, + ); + + it.live("an explicit --yes=false overrides SUPABASE_YES and honors a piped 'n'", () => { + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer } = setup({ + stdinIsTty: false, + pipedAnswer: "n", + cliArgs: ["gen", "signing-key", "--yes=false"], + }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + it.live("flushes telemetry state after the command finishes", () => { const { layer, telemetry } = setup({ trackTelemetry: true }); return Effect.gen(function* () { @@ -447,4 +705,28 @@ describe("legacy gen signing-key integration", () => { expect(telemetry?.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); + + it.live("flushes telemetry state even when the project .env is malformed (Codex review)", () => { + // Go attaches the telemetry service in root's `PersistentPreRunE` (cmd/root.go:131-155), + // before this command's own `RunE` runs `flags.LoadConfig` (signingkeys.go:99), so + // `service.Capture` still fires even when that project-.env load fails. The project-env + // resolution here must live inside the `Effect.ensuring(telemetryState.flush)`-wrapped + // block for the same reason — locks in that fix. + const { layer, telemetry } = setup({ trackTelemetry: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", ".env"), "!=broken\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + } + expect(telemetry?.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index 1bc6151b77..4ef5a89369 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -5,7 +5,8 @@ | Path | Format | When | | ----------------------------------------- | ---------- | ---------------------------------------------------------------------------------------- | | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | -| `/supabase/config.toml` | TOML | when selecting schemas from config; required for `--local`, best-effort otherwise | +| `/supabase/config.toml` | TOML | when selecting schemas; `--local` uses embedded defaults when the file is missing | +| `{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the legacy CLI | | `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | | `/supabase/.temp/pgmeta-version` | plain text | `--local` only — overrides the pg-meta docker image tag | @@ -59,7 +60,12 @@ default 10s pg-delta probe timeout. | Variable | Purpose | Required? | | ---------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | +| `SUPABASE_DB_PORT` | local database probe port | no (defaults to `54322`) | +| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | +| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | +| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | | `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry) | no (defaults to the ECR registry) | @@ -95,6 +101,8 @@ Not applicable. ## Notes - Exactly one of `--local`, `--linked`, `--project-id`, or `--db-url` must be specified. +- With `--local`, a missing `supabase/config.toml` uses the embedded config defaults plus + shell and nested dotenv overrides, matching the legacy CLI. - `--lang` flag accepts `typescript` (default), `go`, `swift`, or `python`. Project-ref paths use the Management API for TypeScript, and use a project database host + temporary login role + pg-meta for other languages. diff --git a/apps/cli/src/legacy/commands/gen/types/types.command.ts b/apps/cli/src/legacy/commands/gen/types/types.command.ts index 341e9dfa38..1e1e78921e 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.command.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.command.ts @@ -75,7 +75,7 @@ export const legacyGenTypesCommand = Command.make("types", config).pipe( ]), Command.withHandler((flags) => legacyGenTypes(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-id"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-id"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 6008a3849e..3f090e6090 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -7,6 +7,10 @@ import { LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { + cobraMutuallyExclusiveErrorMessage, + hasExplicitLongFlag, +} from "../../../../shared/cli/cobra-flag-groups.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; import { @@ -22,6 +26,10 @@ import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConfigFlags } from "../../../shared/legacy-db-config.types.ts"; import { legacyPoolerConfigFromConnectionString } from "../../../shared/legacy-db-config.parse.ts"; +import { + legacyApplyProjectEnv, + legacyReadDbToml, +} from "../../../shared/legacy-db-config.toml-read.ts"; import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { legacyTempPaths } from "../../../shared/legacy-temp-paths.ts"; @@ -79,6 +87,8 @@ function isProjectNotFound(cause: unknown) { return cause instanceof LegacyGenTypesUnexpectedStatusError && cause.status === 404; } +const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; + function ensureMutuallyExclusive( group: ReadonlyArray, present: ReadonlyArray, @@ -86,11 +96,7 @@ function ensureMutuallyExclusive( if (present.length <= 1) { return Effect.void; } - return Effect.fail( - new Error( - `if any flags in the group [${group.join(" ")}] are set none of the others can be; [${present.join(" ")}] were all set`, - ), - ); + return Effect.fail(new Error(cobraMutuallyExclusiveErrorMessage(group, present))); } function forwardByteStream( @@ -177,29 +183,6 @@ function findLegacyPositionalLanguage(rawArgs: ReadonlyArray): Option.Op return Option.none(); } -function hasExplicitLongFlag(rawArgs: ReadonlyArray, flagName: string): boolean { - const commandIndex = rawArgs.findIndex( - (value, index) => value === "types" && rawArgs[index - 1] === "gen", - ); - if (commandIndex === -1) { - return false; - } - - for (let index = commandIndex + 1; index < rawArgs.length; index += 1) { - const token = rawArgs[index]; - if (token === undefined) { - return false; - } - if (token === "--") { - return false; - } - if (token === `--${flagName}` || token.startsWith(`--${flagName}=`)) { - return true; - } - } - return false; -} - export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: LegacyGenTypesFlags) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; @@ -231,7 +214,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le if ( Option.isSome(legacyLang) && legacyLang.value !== "typescript" && - !hasExplicitLongFlag(rawArgs, "lang") + !hasExplicitLongFlag(rawArgs, GEN_TYPES_COMMAND_PATH, "lang") ) { return yield* Effect.fail(new Error("use --lang flag to specify the typegen language")); } @@ -244,7 +227,10 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const swiftAccessControl = flags.swiftAccessControl; const usesPgMeta = flags.local || Option.isSome(flags.dbUrl) || flags.lang !== "typescript"; - if (hasExplicitLongFlag(rawArgs, "swift-access-control") && lang !== "swift") { + if ( + hasExplicitLongFlag(rawArgs, GEN_TYPES_COMMAND_PATH, "swift-access-control") && + lang !== "swift" + ) { return yield* Effect.fail( new Error("--swift-access-control can only be used with --lang swift"), ); @@ -254,7 +240,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le new Error("--postgrest-v9-compat can only be used with pg-meta type generation"), ); } - if (hasExplicitLongFlag(rawArgs, "query-timeout") && !usesPgMeta) { + if (hasExplicitLongFlag(rawArgs, GEN_TYPES_COMMAND_PATH, "query-timeout") && !usesPgMeta) { if (flags.linked || Option.isSome(flags.projectId)) { return yield* Effect.fail( new Error("--query-timeout can only be used with pg-meta type generation"), @@ -266,9 +252,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ); } - const loadConfig = () => loadProjectConfig(cliConfig.workdir); + const loadConfig = () => loadProjectConfig(cliConfig.workdir, { goViperCompat: true }); const loadConfigForRef = (projectRef: string) => - loadProjectConfig(cliConfig.workdir, { projectRef }); + loadProjectConfig(cliConfig.workdir, { projectRef, goViperCompat: true }); const schemasFromConfig = (apiSchemas: ReadonlyArray | undefined) => defaultSchemas(apiSchemas); @@ -545,12 +531,12 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le yield* Effect.gen(function* () { if (flags.local) { - const loaded = yield* loadConfig(); - if (loaded === null) { - return yield* Effect.fail( - new Error("failed to load config: supabase/config.toml not found"), - ); - } + const config = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + yield* legacyApplyProjectEnv( + config.projectEnv, + Object.keys(config.projectEnv).filter((key) => key !== "SUPABASE_DB_PASSWORD"), + ); + const projectId = Option.getOrElse(config.projectId, () => path.basename(cliConfig.workdir)); const paths = legacyTempPaths(path, cliConfig.workdir); // Go resolves Config.Api.Image from the rest-version file only when @@ -558,7 +544,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le // (pkg/config/config.go:657-666, internal/gen/types/types.go:69). Gate and trim // identically so we don't force v9 on older databases. const restVersion = - loaded.config.db.major_version > 14 + config.majorVersion > 14 ? (yield* fs .readFileString(paths.restVersion) .pipe(Effect.orElseSucceed(() => ""))).trim() @@ -569,9 +555,8 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le .pipe(Effect.orElseSucceed(() => "")); const includedSchemas = ( - schemas.length > 0 ? schemas : defaultSchemas(loaded.config.api.schemas) + schemas.length > 0 ? schemas : defaultSchemas(config.apiSchemas) ).join(","); - const projectId = loaded.config.project_id ?? path.basename(cliConfig.workdir); yield* assertLocalDbRunning(projectId); yield* runPgMeta({ @@ -585,7 +570,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le host: "db", port: 5432, probeHost: legacyGetHostname(), - probePort: loaded.config.db.port, + probePort: config.port, networkMode: localNetworkId(projectId), includedSchemas, postgrestV9Compat: flags.postgrestV9Compat || forcedV9, @@ -655,5 +640,5 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), false, ); - }).pipe(Effect.ensuring(telemetryState.flush)); + }).pipe(Effect.scoped, Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index 5407d1bd2d..5d75f2caa9 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import type { @@ -62,7 +62,12 @@ import type { import { legacyGenCommand } from "../gen.command.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; import { legacyGenTypes } from "./types.handler.ts"; -import { parseQueryTimeoutSeconds, resolvePgmetaImage } from "./types.shared.ts"; +import { + localDbContainerId, + localNetworkId, + parseQueryTimeoutSeconds, + resolvePgmetaImage, +} from "./types.shared.ts"; function writeConfig(workdir: string, contents: string) { const supabaseDir = join(workdir, "supabase"); @@ -788,8 +793,10 @@ describe("legacy gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { + // cobra sorts the violating-flag set alphabetically (sort.Strings) — + // "linked" before "local" — regardless of check order. expect(String(exit.cause)).toContain( - "if any flags in the group [local linked project-id db-url] are set none of the others can be; [local linked] were all set", + "if any flags in the group [local linked project-id db-url] are set none of the others can be; [linked local] were all set", ); } }); @@ -2629,22 +2636,98 @@ describe("legacy gen types", () => { }, ); - it.live("fails local generation when supabase/config.toml is missing", () => { + it.live("generates locally with Go defaults when supabase/config.toml is missing", () => { const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-")); - const { layer } = setup({ workdir, skipConfig: true }); + const docker = captureDockerRun(); + const probes: Array<{ host: string; port: number }> = []; + const { layer, out, child } = setup({ + workdir, + skipConfig: true, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: (host, port) => + Effect.sync(() => { + probes.push({ host, port }); + return false; + }), + }), + }); return Effect.gen(function* () { - const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.exit, + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + const projectId = basename(workdir); + expect(child.spawned[0]).toEqual({ + command: "docker", + args: ["container", "inspect", localDbContainerId(projectId)], + }); + expect(child.spawned[1]?.args).toContain(localNetworkId(projectId)); + expect(probes).toEqual([{ host: "127.0.0.1", port: 54322 }]); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,graphql_public")).toBe( + true, ); + expect(out.stdoutText).toContain("generated"); + }); + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "failed to load config: supabase/config.toml not found", - ); - } + it.live("honors local dotenv overrides when supabase/config.toml is missing", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-env-")); + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync( + join(supabaseDir, ".env"), + [ + "SUPABASE_PROJECT_ID=configless-env-project", + "SUPABASE_DB_PORT=55432", + "SUPABASE_DB_PASSWORD=remote-password", + "SUPABASE_API_SCHEMAS=private,graphql_public", + "SUPABASE_SERVICES_HOSTNAME=host.docker.internal", + "SUPABASE_INTERNAL_IMAGE_REGISTRY=mirror.example.com", + "", + ].join("\n"), + ); + const docker = captureDockerRun(); + const probes: Array<{ host: string; port: number }> = []; + const { layer, out, child } = setup({ + workdir, + skipConfig: true, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: (host, port) => + Effect.sync(() => { + probes.push({ host, port }); + return false; + }), + }), + }); + + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + expect(child.spawned[0]).toEqual({ + command: "docker", + args: ["container", "inspect", localDbContainerId("configless-env-project")], + }); + expect(child.spawned[1]?.args).toContain(localNetworkId("configless-env-project")); + expect(probes).toEqual([{ host: "host.docker.internal", port: 55432 }]); + expect( + docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private,graphql_public"), + ).toBe(true); + expect( + docker.env.has( + "PG_META_DB_URL=postgresql://postgres:postgres@db:5432/postgres?connect_timeout=10", + ), + ).toBe(true); + expect( + child.spawned[1]?.args.some((arg) => + arg.startsWith("mirror.example.com/supabase/postgres-meta:"), + ), + ).toBe(true); + expect(out.stdoutText).toContain("generated"); }); }); diff --git a/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md index f5728c86be..7bfc41e7a1 100644 --- a/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md @@ -40,7 +40,7 @@ no new config reads. | ---------------------------------------------------- | --------------------------------- | --------------------------------------- | | `SUPABASE_DB_PASSWORD` / `DB_PASSWORD` | database password (linked/local) | no (prompts / config fallback) | | `SUPABASE_ACCESS_TOKEN` | Management API auth (linked only) | no (falls back to keyring / token file) | -| `PROJECT_ID` | project ref fallback (linked) | no (config resolution fallback) | +| `SUPABASE_PROJECT_ID` | project ref fallback (linked) | no (config resolution fallback) | | libpq vars (`PGSSLROOTCERT`, `PGCONNECT_TIMEOUT`, …) | honored when `--db-url` is used | no | ## Database Queries diff --git a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md index 3da2b98fdb..9307ddcd2a 100644 --- a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md @@ -54,12 +54,12 @@ resolve the connection (via `LegacyDbConfigResolver`). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | -| `SUPABASE_API_URL` | override Management API base URL | no | -| `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | -| `SUPABASE_ENV` | selects which project `.env` files load | no | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | +| `SUPABASE_ENV` | selects which project `.env` files load | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md index 7da5a26931..398edd9745 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md @@ -2,9 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -20,9 +21,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------ | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_YES` | auto-confirm the overwrite prompt | no — read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878) | ## Exit Codes @@ -54,8 +56,9 @@ Same structured `files` result delivered as an NDJSON `result` event. - When the migrations directory is non-empty, prompts `Do you want to overwrite existing files in supabase/migrations directory?` - (default **YES**). Declining exits non-zero (`context canceled`). `--yes` - auto-confirms; a non-interactive / machine-output run takes the default (YES). + (default **YES**). Declining exits non-zero (`context canceled`). `--yes` or + `SUPABASE_YES` (shell env or project `.env`) auto-confirms; a non-interactive / + machine-output run takes the default (YES). ## Notes diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts index ccc5b69f43..6fadfea2b4 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts @@ -1,6 +1,9 @@ import { Effect, FileSystem, Option, Path } from "effect"; -import { LegacyDnsResolverFlag, legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { + LegacyDnsResolverFlag, + legacyResolveYesWithProjectEnv, +} from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -8,6 +11,7 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; import { legacyReadMigrationTable } from "../../../shared/legacy-migration-history.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -31,8 +35,10 @@ const runFetch = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; - const yes = yield* legacyResolveYes; // --yes OR SUPABASE_YES (Go viper AutomaticEnv, root.go:318-334). + // Flag-group mutual-exclusion first: cobra's `MarkFlagsMutuallyExclusive` validates at + // parse time, ahead of the root `PersistentPreRunE` (same ordering as `migration down`/ + // `repair`). if (target.setFlags.length > 1) { return yield* Effect.fail( new LegacyMigrationTargetFlagsError({ @@ -55,6 +61,14 @@ const runFetch = Effect.fnUntraced(function* ( dnsResolver, }); + // Go loads the project .env via loadNestedEnv INSIDE ParseDatabaseConfig (config.go:701), + // i.e. 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 (root.go:318-334). + // Same ordering as `migration down`/`repair`. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + // Linked fetch caches the project ref on success (Go's `PersistentPostRun`). The ref is // loaded now (pre-run), but the cache write is attached to the body via `Effect.ensuring`, // so a declined prompt returns before it runs — matching Go (PostRun is skipped on a diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts index f0fcd50ccf..b4c408487d 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts @@ -43,6 +43,8 @@ interface SetupOpts { readonly confirm?: boolean; readonly rows?: ReadonlyArray; readonly resolveFails?: boolean; + /** Raw argv seen by `resolveLegacyDbTargetFlags` (e.g. to exercise a flag conflict). */ + readonly cliArgs?: ReadonlyArray; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -109,7 +111,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir }), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(LegacyYesFlag, opts.yes ?? false), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), mockTty({ stdinIsTty: opts.isTTY ?? true }), mockStdin( opts.isTTY ?? true, @@ -237,6 +239,27 @@ describe("legacy migration fetch", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "auto-confirms the overwrite prompt from SUPABASE_YES in the project .env (Go loadNestedEnv)", + () => { + // SUPABASE_YES lives only in supabase/.env, not the shell — `fetch` defaults to + // `--linked` (Go: migration.go:161), and root's `ParseDatabaseConfig` loads the project + // `.env` files before `fetch.Run`'s overwrite prompt (root.go:118), so the overwrite + // auto-confirms with no --yes flag and no piped stdin answer (CLI-1878). + mkdirSync(migrationsDir(tmp.current), { recursive: true }); + writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + const { layer, out } = setup(tmp.current, { + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags()); + expect(out.stderrText).toContain("[Y/n] y"); + expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("still prompts on stderr in json mode and proceeds on a piped yes", () => { // Go writes the prompt to stderr and reads stdin regardless of --output (console.go), // so --output-format json must NOT silently auto-accept: the overwrite prompt fires on @@ -332,8 +355,12 @@ describe("legacy migration fetch", () => { }); it.live("reports a write failure", () => { - // A file at /supabase makes `makeDirectory(supabase/migrations)` fail. - writeFileSync(join(tmp.current, "supabase"), "not a directory"); + // A file at /supabase/migrations makes `makeDirectory` fail. `supabase` itself + // must stay a real directory here: the handler's project-env load (CLI-1878, honoring + // Go's `loadNestedEnv`) reads `/supabase/.env*` before this mkdir, and a plain + // file at `/supabase` would make that read fail first (ENOTDIR) instead. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const { layer } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); @@ -362,4 +389,33 @@ describe("legacy migration fetch", () => { expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); + + it.live( + "rejects --db-url combined with --linked before reading the project .env (CLI-1878)", + () => { + // Cobra's `MarkFlagsMutuallyExclusive` validates at parse time, ahead of the root + // `PersistentPreRunE` that runs `ParseDatabaseConfig`/`loadNestedEnv` — so a flag + // conflict must surface even when `supabase/.env` is malformed (which would abort a + // project-env load with a DIFFERENT error, `LegacyDbConfigLoadError`, if the env load + // ran first). Locks in the fix that reordered the project-env load in `fetch.handler.ts` + // to run after this flag-group check. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "!=broken\n"); + const { layer } = setup(tmp.current, { + cliArgs: ["--db-url", "postgresql://x", "--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch( + flags({ dbUrl: Option.some("postgresql://x") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyMigrationTargetFlagsError", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts index d00659ed76..0b6859a907 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts @@ -55,8 +55,9 @@ export const legacyMigrationRepairCommand = Command.make("repair", config).pipe( // `password` is a credential — always reaches telemetry as ``. password: flags.password, }, - // Go records `utils.EnumFlag` values verbatim (`--status`); password stays redacted. - safeFlags: ["status"], + // --status is Flag.choice and is auto-detected as safe via `config` + // below (Go's isEnumFlag, cmd/root_analytics.go:110-116); password stays redacted. + config, aliases: { p: "password" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md index f9042c22a7..edfa550b89 100644 --- a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -27,8 +27,8 @@ The Management API exposes this read operation as `POST .../network-bans/retriev | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md index 333bf103a1..937970c60d 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -27,8 +27,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md index 7c746dbd01..f39c04d7c9 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md index fb31633592..f30b5ed87e 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -31,8 +31,8 @@ when no `--db-allow-cidr` was supplied), matching Go's `&[]string{}` initializat | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md index 69bd23294e..97168c0975 100644 --- a/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -28,8 +28,8 @@ This command does not call a delete endpoint. It mirrors Go: fetch current confi | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md index 6582161bd1..d969c35982 100644 --- a/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md index edd0de4c4a..8cfba86318 100644 --- a/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -28,8 +28,8 @@ The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwi | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md index 23e0b9711e..fa2e82029d 100644 --- a/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md @@ -41,7 +41,7 @@ subcommand's own `SIDE_EFFECTS.md`. | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROJECT_REF` | linked project ref (via the config layer) | no (used by `list` marker / `api-keys` ref / `delete` unlink) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | > `DB_PASSWORD` is **not** consumed. In Go it only mirrors `--db-password` via a > viper binding for downstream local-stack use; `projects create` never reads it. diff --git a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md index 79a7642c2c..b0207e720a 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Management API to return the full secret keys (`sb_secret_...`) in `api_key` ins | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Flags diff --git a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md index 0726aaa348..1a9d18c419 100644 --- a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md @@ -24,7 +24,7 @@ | Variable | Purpose | Required? | | ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `DB_PASSWORD` | **not consumed** — Go only mirrors `--db-password` into viper for local-stack reuse; `projects create` never reads it | n/a | ## Exit Codes @@ -40,22 +40,22 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--org-id`, `--high-availability` are telemetry-safe) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--org-id` is telemetry-safe, matching Go) | ## Flags -| Flag | Type | Required (non-interactive) | Description | -| --------------------- | ------ | -------------------------- | ----------------------------------------------- | -| `[project name]` | arg | yes (non-interactive) | Name of the project (positional argument) | -| `--org-id` | string | yes (non-interactive) | Organization ID (slug) to create the project in | -| `--db-password` | string | yes (non-interactive) | Database password for the project | -| `--region` | enum | yes (non-interactive) | AWS region for the project | -| `--size` | enum | no | Desired instance size | -| `--high-availability` | bool | no | Enable high availability for the project | -| `--interactive` | bool | no (default: true) | Enable interactive mode (hidden flag) | -| `--plan` | string | no | Plan selection (hidden flag) | +| Flag | Type | Required (non-interactive) | Description | +| --------------------- | ------ | -------------------------- | ---------------------------------------------------------------------------- | +| `[project name]` | arg | yes (non-interactive) | Name of the project (positional argument) | +| `--org-id` | string | yes (non-interactive) | Organization ID (slug) to create the project in | +| `--db-password` | string | yes (non-interactive) | Database password for the project | +| `--region` | enum | yes (non-interactive) | AWS region for the project | +| `--size` | enum | no | Desired instance size | +| `--high-availability` | bool | no | Enable high availability for the project (**TS-only, no Go CLI equivalent**) | +| `--interactive` | bool | no (default: true) | Enable interactive mode (hidden flag) | +| `--plan` | string | no | Plan selection (hidden flag) | ## Output @@ -93,4 +93,8 @@ One `result` event on success. flags and the positional project name argument are required. - The `--size` flag, when provided, sets the `desired_instance_size` field in the request body. - The `--high-availability` flag, when provided, sets the `high_availability` field in the request body. + This is a TS-only flag with no Go CLI equivalent: `apps/cli-go/cmd/projects.go`'s `init()` (~line 133) + never registers a `high-availability` flag, and the create command's `RunE` closure (~line 74) never sets + `HighAvailability` on the request body, even though the underlying API field exists — matching how + `--reveal` is disclosed on `projects api-keys`. - The `--plan` flag is hidden and reserved. diff --git a/apps/cli/src/legacy/commands/projects/create/create.command.ts b/apps/cli/src/legacy/commands/projects/create/create.command.ts index 2486931973..29e63c37ad 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.command.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.command.ts @@ -27,25 +27,24 @@ const AWS_REGIONS = [ ] as const; const INSTANCE_SIZES = [ - "nano", - "micro", - "small", - "medium", "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", + "medium", + "micro", "12xlarge", "16xlarge", "24xlarge", "24xlarge_high_memory", "24xlarge_optimized_cpu", "24xlarge_optimized_memory", + "2xlarge", "48xlarge", "48xlarge_high_memory", "48xlarge_optimized_cpu", "48xlarge_optimized_memory", + "4xlarge", + "8xlarge", + "small", + "xlarge", ] as const; const config = { @@ -69,6 +68,10 @@ const config = { Flag.withDescription("Select a desired instance size for your project."), Flag.optional, ), + // TS-only, no Go CLI equivalent: `cmd/projects.go`'s `init()` never registers a + // `high-availability` flag, and the `RunE` closure's `api.V1CreateProjectBody{...}` + // never sets `HighAvailability` even though the API field exists — disclosed in + // SIDE_EFFECTS.md, matching how `--reveal` is disclosed on `projects api-keys`. highAvailability: Flag.boolean("high-availability").pipe( Flag.withDescription("Enable high availability for the project."), Flag.optional, @@ -99,7 +102,13 @@ export const legacyProjectsCreateCommand = Command.make("create", config).pipe( ]), Command.withHandler((flags) => legacyProjectsCreate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["org-id", "high-availability"] }), + // `high-availability` is intentionally not in `safeFlags`: Go marks only + // `org-id` telemetry-safe (`markFlagTelemetrySafe`), and it's a boolean flag + // anyway — boolean values are always logged verbatim by the instrumentation + // regardless of `safeFlags`. See the same pattern on `projects api-keys`'s + // `--reveal`. `config` is passed so `region`/`size` (both `Flag.choice`) + // are auto-detected as telemetry-safe, matching Go's `isEnumFlag`. + withLegacyCommandInstrumentation({ flags, safeFlags: ["org-id"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts index f21138df6c..d38c35c021 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts @@ -1,8 +1,10 @@ import type { OrganizationResponseV1, V1CreateAProjectOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Option } from "effect"; +import { Command } from "effect/unstable/cli"; import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; import { type LegacyApiResponse, type LegacyHttpMethod, @@ -13,7 +15,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import type { LegacyProjectsCreateFlags } from "./create.command.ts"; +import { legacyProjectsCreateCommand, type LegacyProjectsCreateFlags } from "./create.command.ts"; import { legacyProjectsCreate } from "./create.handler.ts"; const CREATED: typeof V1CreateAProjectOutput.Type = { @@ -451,4 +453,54 @@ describe("legacy projects create integration", () => { expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); + + // Go parity (`apps/cli-go/cmd/projects.go:34-55`): Go's --size EnumFlag is an + // 18-value list that does not include "nano" (or "pico") and rejects any other + // value at flag-parse time. TS previously listed "nano" as a valid choice, + // silently succeeding where Go errors. + it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { + const root = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyProjectsCreateCommand]), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(root, { version: "0.0.0-test" })([ + "create", + "alpha", + "--org-id", + "acme", + "--db-password", + "s3cret-pass", + "--region", + "us-east-1", + "--size", + "nano", + ]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(rejectsInvalidSizeChoice(Cause.squash(exit.cause))).toBe(true); + } + }) as Effect.Effect; + }); }); + +// Distinguishes "the --size flag itself was rejected at parse time" from any +// other failure (e.g. a missing runtime service in this minimal test setup), +// so the regression test above can't pass for the wrong reason. +function rejectsInvalidSizeChoice(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("errors" in error)) return false; + const { errors } = error; + if (!Array.isArray(errors)) return false; + return errors.some( + (candidate: unknown) => + typeof candidate === "object" && + candidate !== null && + "_tag" in candidate && + candidate._tag === "InvalidValue" && + "option" in candidate && + candidate.option === "size", + ); +} diff --git a/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md index f4e46eab50..3d94401b92 100644 --- a/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md @@ -30,7 +30,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md index f4785c0f14..8ddce1209a 100644 --- a/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md @@ -24,7 +24,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md index a7a248607e..9a1ce5d299 100644 --- a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md @@ -32,7 +32,6 @@ | `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:` (Go parity — 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`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts index 7094cf2336..385c0911fe 100644 --- a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts @@ -82,9 +82,3 @@ export class LegacySecretsUnsetCancelledError extends Data.TaggedError( )<{ readonly message: string; }> {} - -export class LegacySecretsConfigParseError extends Data.TaggedError( - "LegacySecretsConfigParseError", -)<{ - readonly message: string; -}> {} diff --git a/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md index c99884485e..fa3c8eec26 100644 --- a/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md @@ -2,17 +2,17 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | -| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.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/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadProjectConfig` | -| `/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values | -| `/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context | -| `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.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/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadProjectConfig`; a parse failure is logged to the debug logger and tolerated (Go parity), not fatal | +| `/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values | +| `/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context | +| `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | ## Files Written @@ -37,7 +37,6 @@ | `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`) | | `env(VAR)` references | values matching `env(NAME)` in `[edge_runtime.secrets]` are resolved against the loaded env. Missing variables preserve the literal verbatim (Go parity). | — | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes @@ -52,7 +51,6 @@ | `1` | `LegacyInvalidSecretPairError` — positional argument missing `=` | | `1` | `LegacySecretsEnvFileOpenError` — `--env-file` cannot be opened | | `1` | `LegacySecretsEnvFileParseError` — `--env-file` cannot be parsed | -| `1` | `LegacySecretsConfigParseError` — `supabase/config.toml` cannot be parsed | | `1` | `LegacySecretsSetUnexpectedStatusError` — non-2xx response from POST | | `1` | `LegacySecretsSetNetworkError` — transport-level network failure | @@ -85,4 +83,5 @@ One `result` NDJSON event on success containing `{project_ref, count}`. - Source order for merging entries: `[edge_runtime.secrets]` from `config.toml` (only resolved entries — see below) → `--env-file` (overrides config) → CLI args (overrides env-file). - `SUPABASE_`-prefixed entries are skipped post-merge with a stderr warning. - `[edge_runtime.secrets]` from config.toml is read via `@supabase/config`'s `loadProjectConfig` + `resolveProjectSubtree`. Resolved secret values arrive wrapped in `Redacted`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered out at the handler — matches Go's `set.go:48-52` which filters by `len(secret.SHA256) > 0` (the SHA256 is empty when `DecryptSecretHookFunc` sees a still-literal `env(VAR)`). +- A malformed `config.toml` does **not** abort the command — matches Go's `set.go:20-24`, which logs the `LoadConfig` error to the debug logger and proceeds. `--env-file` and positional `NAME=VALUE` secrets always still apply. What happens to config-declared secrets depends on the failure class, matching Go's `viper`+`mapstructure` decode (`pkg/config/config.go:749`), which mutates the target struct field-by-field: a raw TOML/JSON syntax error drops everything (no `EdgeRuntime.Secrets`), but a schema-type error on an _unrelated_ field (e.g. `analytics.port` being a string) still leaves a valid `[edge_runtime.secrets]` section usable — the handler recovers it by re-decoding just that subtree. Pass `--debug` to see the logged parse error. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers — Go parity. diff --git a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts index 422f503070..fed1646688 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts @@ -1,9 +1,18 @@ -import { loadProjectConfig, loadProjectEnvironment, resolveProjectSubtree } from "@supabase/config"; +import { + loadProjectConfig, + loadProjectEnvironment, + ProjectConfigSchema, + resolveProjectSubtree, + type ProjectConfig, + type ProjectConfigParseError, +} from "@supabase/config"; +import { V1BulkCreateSecretsInput } from "@supabase/api/effect"; import { parse as parseDotenv } from "dotenv"; -import { Effect, FileSystem, Option, Path, Redacted } from "effect"; +import { Effect, FileSystem, Option, Path, Redacted, Schema } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -11,7 +20,6 @@ import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts" import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyInvalidSecretPairError, - LegacySecretsConfigParseError, LegacySecretsEnvFileOpenError, LegacySecretsEnvFileParseError, LegacySecretsNoArgumentsError, @@ -27,12 +35,118 @@ const mapSetError = mapLegacyHttpError({ statusMessage: (_status, body) => `Unexpected error setting project secrets: ${body}`, }); +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +// Excludes arrays, matching `packages/config/src/io.ts`'s `isObject` (the +// identical "is this a table" check used when merging `[remotes.*]`). A TOML +// array for a map-typed field (e.g. `[edge_runtime] secrets = ["actual-secret"]`) +// is not a recoverable table: `Object.entries` on an array yields index keys +// ("0", "1", ...), which would otherwise fabricate spurious secret names. Go's +// mapstructure decoder never does this either — `UnmarshalExact` +// (`apps/cli-go/pkg/config/config.go:749`) never sets `WeaklyTypedInput`, so a +// slice source for a map-typed field hits `UnconvertibleTypeError` in +// `decodeMap` rather than the index-as-key `decodeMapFromSlice` path, and the +// whole field is left empty. +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Best-effort recovery for a schema-decode failure (as opposed to a raw + * TOML/JSON parse failure) on `supabase/config.toml`. Go's `viper`+ + * `mapstructure` decode (`apps/cli-go/pkg/config/config.go:749`) mutates the + * target struct field-by-field: a type error anywhere — an unrelated + * top-level table (`analytics.port`), a sibling field inside the same + * `edge_runtime` table (`edge_runtime.inspector_port`), *or* a single bad + * entry inside the `edge_runtime.secrets` map itself (`BAD = 123`) — does not + * stop the rest of `edge_runtime.secrets` from landing in `utils.Config`. + * `UnmarshalExact` still populates every field (and every map entry) it *can* + * decode before aggregating errors: `mapstructure`'s map decoder + * (`decodeMapFromMap`) iterates each key independently, appends a per-entry + * error and `continue`s rather than aborting, then still calls `val.Set` with + * whatever entries succeeded. Confirmed empirically against this repo's + * actual `pkg/config`: a TOML with both a malformed `edge_runtime.inspector_port` + * and a valid `[edge_runtime.secrets]` block still yields a populated + * `EdgeRuntime.Secrets` (`InspectorPort` is left at its zero value), and a + * `[edge_runtime.secrets]` block with one bad entry alongside a good one + * still yields the good entry. + * `Schema.decodeUnknownSync` has no such tolerance; a single bad field + * anywhere discards the whole decode — re-decoding the *entire* `edge_runtime` + * subtree would still fail in the sibling-field case (`inspector_port` comes + * along for the ride), and re-decoding the whole `secrets` map atomically + * would still fail when just one entry in that map is bad. To keep + * `secrets set` at parity without loosening `packages/config`'s decode + * semantics for every caller: re-slice `edge_runtime.secrets` out of the + * pre-decode document (`cause.document` — only set when the document itself + * parsed fine and the *schema* decode is what failed, see + * `ProjectConfigParseError`), decode each entry independently and keep only + * the ones that succeed (mirroring `decodeMapFromMap`'s per-key tolerance), + * then decode the filtered map against the full schema, where every other + * field (including the rest of `edge_runtime`) defaults cleanly. A true parse + * failure (`cause.document` undefined) has no recoverable structure in either + * implementation — Go's own `viper.MergeConfig` also fails the whole load + * before `mapstructure` ever runs in that case. + */ +function recoverEdgeRuntimeConfig(cause: ProjectConfigParseError): ProjectConfig | null { + if (cause.document === undefined) { + return null; + } + const edgeRuntime = cause.document.edge_runtime; + const secretsField = isRecord(edgeRuntime) ? edgeRuntime.secrets : undefined; + // `redactEdgeRuntimeSecrets` (`packages/config/src/io.ts`) wraps a malformed, + // non-object `secrets` field (e.g. a TOML array) in a single `Redacted` + // rather than leaving it a plain record, so an uncaught error can't leak it + // either. Unwrap before the `isRecord` check below — otherwise the + // `Redacted` wrapper object itself (an object, just not a secrets map) gets + // misread as a one-entry map and fabricates a bogus secret from its + // internal fields. + const secrets = Redacted.isRedacted(secretsField) ? Redacted.value(secretsField) : secretsField; + const decodableSecrets = isRecord(secrets) ? filterDecodableSecrets(secrets) : undefined; + try { + return decodeProjectConfig({ + edge_runtime: decodableSecrets !== undefined ? { secrets: decodableSecrets } : {}, + }); + } catch { + return null; + } +} + +/** + * Mirrors mapstructure's per-entry map decode tolerance + * (`decodeMapFromMap`, invoked via `v.UnmarshalExact` in + * `apps/cli-go/pkg/config/config.go:749`): a decode error on one secret + * value doesn't discard the whole `[edge_runtime.secrets]` map — only that + * entry is dropped, and every other entry is still recovered. + * + * Each value arrives wrapped in `Redacted` (whatever its underlying type) — + * `ProjectConfigParseError.document` wraps every `edge_runtime.secrets` entry + * so an uncaught parse error can't leak a resolved secret, or a malformed + * non-string entry, into a log or trace (see the field doc on `.document`). + * Unwrap before re-decoding: `secret()`'s schema is a plain `Schema.String`, + * not `Redacted`, and a non-string entry (e.g. an array) still fails that + * decode and is dropped below, same as it would in Go. + */ +function filterDecodableSecrets(secrets: Record): Record { + const kept: Record = {}; + for (const [name, value] of Object.entries(secrets)) { + const plainValue = Redacted.isRedacted(value) ? Redacted.value(value) : value; + try { + decodeProjectConfig({ edge_runtime: { secrets: { [name]: plainValue } } }); + kept[name] = plainValue; + } catch { + // Drop this entry only, matching mapstructure's per-key error handling. + } + } + return kept; +} + export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( flags: LegacySecretsSetFlags, ) { const output = yield* Output; const api = yield* LegacyPlatformApi; const resolver = yield* LegacyProjectRefResolver; + const debugLogger = yield* LegacyDebugLogger; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const runtimeInfo = yield* RuntimeInfo; @@ -53,28 +167,147 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( // literals stay as plain strings, so `Redacted.isRedacted(...)` is the // equivalent guard. const merged = new Map(); - const loaded = yield* loadProjectConfig(runtimeInfo.cwd).pipe( - Effect.catchTag("ProjectConfigParseError", (cause) => - Effect.fail( - new LegacySecretsConfigParseError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, - }), - ), + // Go swallows a malformed config.toml (or a malformed `.env`/`.env.local` + // sibling — see the `ProjectEnvParseError` catch below) here + // (`internal/secrets/set/set.go:20-24`: `fmt.Fprintln(utils.GetDebugLogger(), err)`) + // and proceeds with an empty `EdgeRuntime.Secrets` — env-file and + // positional-arg secrets still work. `secrets set` has no + // `--linked`/`--local`/`--db-url` flag, so (unlike most commands) the root + // `PreRun` never loads the config first either; this is the only load, and + // it must not be fatal. + // + // Pass `ref` so a matching `[remotes.*]` block is merged over the base + // config before decode, mirroring Go's `flags.LoadConfig` + // (`internal/utils/flags/config_path.go:11-12`: `utils.Config.ProjectId = + // ProjectRef` before `Load()`) merging the override in `loadFromFile` + // (`pkg/config/config.go:604-609`) ahead of the tolerant decode below. + // Without this, a schema-decode error on `--project-ref ` + // would recover the *base* `[edge_runtime.secrets]` instead of the + // explicitly selected remote's override. + // `goViperCompat: true` opts into `applyRemoteOverride`'s duplicate- + // `project_id`/format checks (`packages/config/src/io.ts`) — required for + // the `DuplicateRemoteProjectIdError` catch below to ever fire. + const loadedConfig = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }).pipe( + Effect.flatMap((loaded) => { + if (loaded === null) { + return Effect.succeed(null); + } + // Go prints this from inside config load, before any command output + // (`pkg/config/config.go:605`) — unconditionally on a matching + // `[remotes.*]` block, ahead of the (possibly failing) decode. Other + // legacy handlers surface it the same way (e.g. `config push`); this + // path must not silently drop it just because it maps straight down + // to `.config` below. + return ( + loaded.appliedRemote !== undefined + ? output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr") + : Effect.void + ).pipe(Effect.as(loaded.config)); + }), + Effect.catchTag("ProjectConfigParseError", (cause) => { + // `smol-toml`'s `TomlError` embeds a source codeblock after a + // blank-line separator — literal file content, which for this file's + // `[edge_runtime.secrets]` section can include real secret values. + // Truncating before the separator handles that case (`cause.document + // === undefined`, a raw parse failure with no decoded document to + // recover from — see the field doc on `ProjectConfigParseError`). + // + // A schema-decode error (`cause.document !== undefined`) has no such + // separator: Effect's `ParseError` puts the rejected value inline on + // one line (e.g. `Expected string, actual ["actual-secret"]`), which + // the truncation above wouldn't catch. Go's pinned mapstructure + // decode-error types (`UnconvertibleTypeError.Error()`, + // `DecodeError.Error()`, `github.com/go-viper/mapstructure/v2 + // v2.5.0`) never include the rejected value, only type names — so a + // fixed, content-free message here matches Go's actual behaviour + // rather than just being defensive. + const shortMessage = + cause.document === undefined + ? String(cause.cause).split("\n\n")[0] + : "schema validation failed"; + // Go prints the override notice unconditionally as soon as a + // `[remotes.*]` block's `project_id` matches, *before* `mapstructure` + // decode ever runs (`pkg/config/config.go:604-609`) — so the notice is + // still owed here even though decode subsequently failed and this + // whole load is non-fatal. `cause.appliedRemote` carries that match + // through the failed decode (see the field doc on + // `ProjectConfigParseError.appliedRemote`); the success path above + // handles the non-error case. Emitted ahead of the debug log below to + // match Go's actual order: the print happens inside `loadFromFile`, + // the debug log only after `flags.LoadConfig` returns the swallowed + // error to `Run` (`internal/secrets/set/set.go:20-24`). + return ( + cause.appliedRemote !== undefined + ? output.raw(`Loading config override: [remotes.${cause.appliedRemote}]\n`, "stderr") + : Effect.void + ).pipe( + Effect.andThen( + debugLogger.debug(`failed to parse supabase/config.toml: ${shortMessage}`), + ), + Effect.as(recoverEdgeRuntimeConfig(cause)), + ); + }), + // `loadProjectConfig` resolves `env(VAR)` references against + // `.env`/`.env.local` (`loadProjectEnvironment` inside + // `loadProjectConfigFile`) *before* schema decode, so a malformed dotenv + // line fails with this distinct tag rather than `ProjectConfigParseError`. + // Go's `Load()` (`pkg/config/config.go:788-791`) calls `loadNestedEnv` + // first too and returns immediately on error, before `loadFromFile` (the + // TOML parse) ever runs — so `EdgeRuntime.Secrets` never gets populated + // in this failure path, unlike the schema-decode-only case above. Recover + // to `null`, not `recoverEdgeRuntimeConfig`: there is no parsed document + // to recover a subtree from. + Effect.catchTag("ProjectEnvParseError", (cause) => + debugLogger.debug(`failed to parse ${cause.path}:${cause.line}`).pipe(Effect.as(null)), + ), + // Two `[remotes.*]` blocks declare the same `project_id` as `ref` — Go's + // `flags.LoadConfig` swallows *any* `Load()` error non-fatally + // (`internal/secrets/set/set.go:22-24`), including this one, which + // `loadFromFile` raises before `mapstructure` ever runs + // (`pkg/config/config.go:601`). `cause.message` already matches Go's + // string verbatim (see `DuplicateRemoteProjectIdError`'s field doc). + Effect.catchTag("DuplicateRemoteProjectIdError", (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(null)), + ), + // A `[remotes.*]` block's `project_id` fails Go's ref-pattern check — + // raised from `Config.Validate` (`pkg/config/config.go:996-1001`), which + // runs inside the same `Config.Load()` call as the duplicate check above + // (`config.go:882`). Go's `flags.LoadConfig` swallows this the same + // non-fatal way (`internal/secrets/set/set.go:22-24`), so a malformed + // remote block must not abort an otherwise-valid `secrets set`. + // `cause.message` already matches Go's string verbatim (see + // `InvalidRemoteProjectIdError`'s field doc). + Effect.catchTag("InvalidRemoteProjectIdError", (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(null)), ), ); - if (loaded !== null) { + if (loadedConfig !== null) { const projectEnv = yield* loadProjectEnvironment({ cwd: runtimeInfo.cwd, baseEnv: process.env, }); if (projectEnv !== null) { const resolved = yield* resolveProjectSubtree( - loaded.config.edge_runtime, + loadedConfig.edge_runtime, projectEnv, "edge_runtime", + { goViperCompat: true }, ); for (const [name, value] of Object.entries(resolved.secrets ?? {})) { - if (Redacted.isRedacted(value)) { + // Go's `DecryptSecretHookFunc` (`pkg/config/secret.go:98`) never + // hashes an empty value, and `ListSecrets` (`internal/secrets/set/set.go:48-52`) + // only includes config entries with a non-empty SHA256 — so an empty + // `[edge_runtime.secrets]` entry is silently skipped rather than sent + // as an empty-string overwrite of a remote secret. `Redacted.isRedacted` + // already excludes the other SHA256-empty case (a still-literal + // `env(VAR)` reference); check for a non-empty value too so both + // zero-hash cases match. This applies to config-sourced secrets only — + // an explicit `--env-file`/positional `NAME=` below is sent as-is, + // matching Go's unconditional `maps.Copy`/assignment for those sources. + if (Redacted.isRedacted(value) && Redacted.value(value).length > 0) { merged.set(name, Redacted.value(value)); } } @@ -143,8 +376,33 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( ); } + // The Management API caps a single bulk-create request at 100 secrets + // (`V1BulkCreateSecretsInput`'s `isMaxLength(100)` check in `@supabase/api`). + // Go issues one unbatched request (`internal/secrets/set/set.go`), so against + // the capped API a >100-entry env file would be rejected wholesale; split into + // batches of at most 100 so large env files still upload. + const SECRETS_PER_REQUEST = 100; + const batches: Array = []; + for (let i = 0; i < body.length; i += SECRETS_PER_REQUEST) { + batches.push(body.slice(i, i + SECRETS_PER_REQUEST)); + } + + // Validate every batch (per-entry name/value constraints and the 100-item + // cap) before sending any request. Without this, a schema-invalid entry in a + // later batch would only surface after earlier batches had already been + // uploaded, leaving the project partially updated. Decoding fails with the + // same `SchemaError` `bulkCreateSecrets` raises, so `mapSetError` keeps the + // error surface identical to the previous single-call path. + yield* Effect.forEach( + batches, + (batch) => Schema.decodeUnknownEffect(V1BulkCreateSecretsInput)({ ref, body: batch }), + { discard: true }, + ).pipe(Effect.catch(mapSetError)); + const setting = output.format === "text" ? yield* output.task("Setting secrets...") : undefined; - yield* api.v1.bulkCreateSecrets({ ref, body }).pipe( + yield* Effect.forEach(batches, (batch) => api.v1.bulkCreateSecrets({ ref, body: batch }), { + discard: true, + }).pipe( Effect.tapError(() => setting?.fail() ?? Effect.void), Effect.catch(mapSetError), ); diff --git a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts index 524ee4f3f7..29bbaf3fd0 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts @@ -16,8 +16,23 @@ import { mockLegacyPlatformApi, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { legacySecretsSet } from "./set.handler.ts"; +function mockLegacyDebugLoggerTracked() { + const messages: Array = []; + return { + messages, + layer: Layer.succeed(LegacyDebugLogger, { + debug: (message) => + Effect.sync(() => { + messages.push(message); + }), + http: () => Effect.void, + }), + }; +} + // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- @@ -40,6 +55,7 @@ function setup(opts: SetupOpts = {}) { network: opts.network, }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const debugLogger = mockLegacyDebugLoggerTracked(); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -49,8 +65,9 @@ function setup(opts: SetupOpts = {}) { }), mockRuntimeInfo({ cwd: tempRoot.current }), processEnvLayer(opts.env ?? {}), + debugLogger.layer, ); - return { layer, out, api }; + return { layer, out, api, debugLogger }; } function writeConfig(content: string) { @@ -58,6 +75,11 @@ function writeConfig(content: string) { writeFileSync(join(tempRoot.current, "supabase", "config.toml"), content); } +function writeSupabaseDotEnv(content: string) { + mkdirSync(join(tempRoot.current, "supabase"), { recursive: true }); + writeFileSync(join(tempRoot.current, "supabase", ".env"), content); +} + function parsePostBody(body: unknown): Array<{ name: string; value: string }> { // `mockLegacyPlatformApi` JSON-decodes the request body when it parses; this // helper just narrows the type for the test assertions. @@ -101,6 +123,66 @@ describe("legacy secrets set integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("batches large secret sets into requests of at most 100", () => { + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: Array.from({ length: 150 }, (_, i) => `KEY${i}=value${i}`), + }); + expect(api.requests).toHaveLength(2); + const first = parsePostBody(api.requests[0]!.body); + const second = parsePostBody(api.requests[1]!.body); + expect(first).toHaveLength(100); + expect(second).toHaveLength(50); + const names = new Set([...first, ...second].map((entry) => entry.name)); + for (let i = 0; i < 150; i++) { + expect(names.has(`KEY${i}`)).toBe(true); + } + expect(out.stdoutText).toContain("Finished supabase secrets set."); + }).pipe(Effect.provide(layer)); + }); + + it.live("batches 250 secrets into three requests (100/100/50)", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: Array.from({ length: 250 }, (_, i) => `KEY${i}=value${i}`), + }); + expect(api.requests).toHaveLength(3); + expect(parsePostBody(api.requests[0]!.body)).toHaveLength(100); + expect(parsePostBody(api.requests[1]!.body)).toHaveLength(100); + expect(parsePostBody(api.requests[2]!.body)).toHaveLength(50); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "rejects the whole upload when a later batch has an invalid entry (no partial update)", + () => { + const { layer, api } = setup(); + // Index 120 lands in the SECOND batch (batch 0 covers indices 0-99): a + // value exceeding the 24576-byte cap there must fail up-front validation + // before batch 0 (which is otherwise entirely valid) is ever sent. + const secrets = Array.from({ length: 150 }, (_, i) => + i === 120 ? `KEY${i}=${"x".repeat(24577)}` : `KEY${i}=value${i}`, + ); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets, + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("sets secrets from --env-file with a relative path (joined to CWD)", () => { writeFileSync(join(tempRoot.current, "myfile.env"), "FROM_FILE=fromvalue\n"); const { layer, api } = setup(); @@ -216,6 +298,34 @@ LITERAL = "plain-value" }).pipe(Effect.provide(layer)); }); + it.live( + "skips an empty [edge_runtime.secrets] value instead of overwriting a remote secret (Go set.go:48-52 parity)", + () => { + // Go's `DecryptSecretHookFunc` (`pkg/config/secret.go:98`) leaves `SHA256` + // empty for an empty value, and `ListSecrets` only includes entries with + // `len(secret.SHA256) > 0` — so a literal `EMPTY = ""` in config.toml is + // never sent, which prevents it from silently overwriting a same-named + // remote secret with an empty string. + writeConfig( + `[edge_runtime.secrets] +EMPTY = "" +NON_EMPTY = "config-value" +`, + ); + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "NON_EMPTY", value: "config-value" }, + ]); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "does not crash when config.toml has env(NUMERIC_PORT) on an unrelated numeric field (CLI-1489 regression guard)", () => { @@ -317,23 +427,474 @@ FOO = "literal-foo" }).pipe(Effect.provide(layer)); }); - it.live("fails with LegacySecretsConfigParseError when config.toml is malformed", () => { - writeConfig("this is not valid = = toml [[[\n"); - const { layer } = setup(); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - legacySecretsSet({ + it.live( + "tolerates a malformed config.toml, logs it to the debug logger, and still sets CLI-arg secrets", + () => { + writeConfig("this is not valid = = toml [[[\n"); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], - }), + }); + expect(api.requests).toHaveLength(1); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "recovers [edge_runtime.secrets] when an unrelated field fails schema decode (CLI-1867 Go parity)", + () => { + // Valid TOML syntax throughout, but `analytics.port` has the wrong type + // for its schema field. Go's viper+mapstructure decode + // (`pkg/config/config.go:749`) mutates the target struct field-by-field, + // so an unrelated type error doesn't stop `EdgeRuntime.Secrets` from + // landing on `utils.Config` — `secrets set` still reads it. Effect + // Schema's `decodeUnknownSync` is atomic and would otherwise discard the + // whole document, silently dropping `FROM_CONFIG` too. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" + +[analytics] +port = "not-a-number" +`, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySecretsConfigParseError"); - } - }).pipe(Effect.provide(layer)); - }); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "config-value" }, + ]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "recovers [edge_runtime.secrets] when a sibling field in the same edge_runtime table fails schema decode (CLI-1867 Go parity)", + () => { + // Valid TOML syntax throughout, but `edge_runtime.inspector_port` has + // the wrong type for its schema field — a SIBLING of `secrets` inside + // the same `edge_runtime` table, not an unrelated top-level table. Go's + // viper+mapstructure decode (`pkg/config/config.go:749`) mutates the + // target struct field-by-field even within the same table, so + // `EdgeRuntime.Secrets` still lands on `utils.Config` while + // `InspectorPort` is left at its zero value — verified empirically + // against `pkg/config` directly. The recovery must therefore re-decode + // `secrets` on its own rather than the whole `edge_runtime` subtree. + writeConfig( + `[edge_runtime] +inspector_port = "not-a-number" + +[edge_runtime.secrets] +FROM_CONFIG = "config-value" +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "config-value" }, + ]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "tolerates a malformed supabase/.env, logs it to the debug logger, and still sets CLI-arg secrets (CLI-1867 Go parity)", + () => { + // `loadProjectConfig` resolves `env(VAR)` references against + // `supabase/.env`/`.env.local` *before* schema decode, so a malformed + // dotenv line fails with `ProjectEnvParseError` rather than + // `ProjectConfigParseError`. Go's `Load()` (`pkg/config/config.go:788-791`) + // calls `loadNestedEnv` first too and swallows any error the same way + // `flags.LoadConfig` does in `internal/secrets/set/set.go:20-24` — so this + // must not abort the command either. `.env` is only read once a + // `supabase/config.toml`/`.json` is found (`findProjectPaths`), so a + // config.toml must exist here too. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" +`, + ); + writeSupabaseDotEnv("THIS IS NOT A VALID DOTENV LINE\n"); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(api.requests).toHaveLength(1); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "recovers valid [edge_runtime.secrets] entries when a sibling entry in the same map fails schema decode (CLI-1867 Go parity)", + () => { + // `GOOD` is a valid secret value; `BAD` is not (a non-string TOML value + // for a field whose schema expects a string-like secret). Go's + // mapstructure decodes `map[string]Secret` entry-by-entry + // (`decodeMapFromMap`), appending a per-entry error and continuing + // rather than discarding the whole map, so `GOOD` still lands on + // `utils.Config.EdgeRuntime.Secrets` even with `BAD` present. Effect + // Schema's `decodeUnknownSync` is atomic per record and would otherwise + // discard `GOOD` too when re-decoding the whole `secrets` map at once. + writeConfig( + `[edge_runtime.secrets] +GOOD = "config-value" +BAD = 123 +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + const body = parsePostBody(api.requests[0]?.body); + expect(body).toEqual([{ name: "GOOD", value: "config-value" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "skips an empty recovered [edge_runtime.secrets] entry alongside an unrelated schema error (Go set.go:48-52 parity)", + () => { + // Same empty-value skip as the happy path, but exercised through + // `recoverEdgeRuntimeConfig`/`filterDecodableSecrets`: `EMPTY` decodes + // fine on its own (it's a valid, if empty, string), so it must be + // dropped downstream in the same merge loop the happy path uses, not + // resurrected as a false "recoverable" entry. + writeConfig( + `[edge_runtime.secrets] +EMPTY = "" +GOOD = "config-value" + +[analytics] +port = "not-a-number" +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "GOOD", value: "config-value" }, + ]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not fabricate a secret named 0 when [edge_runtime.secrets] is an array (CLI-1867 Go parity)", + () => { + // `edge_runtime.secrets` as an array (instead of a table) is not + // recoverable structure: Go's mapstructure decoder never sets + // `WeaklyTypedInput`, so a slice source for a map-typed field hits + // `UnconvertibleTypeError` in `decodeMap` rather than the index-as-key + // `decodeMapFromSlice` path, and the whole field is left empty. Before + // the `isRecord` fix, `Object.entries(["actual-secret"])` would turn + // this into a spurious `{ "0": "actual-secret" }` entry. + writeConfig( + `[analytics] +port = "not-a-number" + +[edge_runtime] +secrets = ["actual-secret"] +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + const body = parsePostBody(api.requests[0]?.body); + expect(body).toEqual([{ name: "FOO", value: "bar" }]); + expect(body.find((entry) => entry.name === "0")).toBeUndefined(); + expect(debugLogger.messages).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "recovers the selected remote's [edge_runtime.secrets] override, not the base, on schema-decode error (CLI-1867 Go parity)", + () => { + // `analytics.port` is an unrelated schema-decode error that triggers the + // recovery path. `remotes.staging.project_id` matches the ref the + // resolver defaults to (`mockLegacyCliConfig`'s `LEGACY_VALID_REF`), so + // Go seeds `Config.ProjectId` before `Load()` + // (`internal/utils/flags/config_path.go:11-12`) and merges the remote + // override in `loadFromFile` (`pkg/config/config.go:604-609`) before the + // tolerant decode this PR models — the recovered secret must reflect the + // remote's override value, not the base document's. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "base-value" + +[analytics] +port = "not-a-number" + +[remotes.staging] +project_id = "${LEGACY_VALID_REF}" + +[remotes.staging.edge_runtime.secrets] +FROM_CONFIG = "remote-value" +`, + ); + const { layer, out, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "remote-value" }, + ]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + // Go prints the override notice unconditionally as soon as the + // `project_id` match is found, *before* `mapstructure` decode ever + // runs (`pkg/config/config.go:604-609`) — so it's still owed here + // even though the decode that follows fails and recovers. + expect(out.stderrText).toContain("Loading config override: [remotes.staging]\n"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "prints the remote override notice to stderr when [remotes.*] matches the resolved ref (Go parity: pkg/config/config.go:605)", + () => { + // No decode error here — the plain success path. Go's `loadFromFile` + // prints `Loading config override: [remotes.]` to stderr + // unconditionally whenever a `[remotes.*]` block's `project_id` matches + // `Config.ProjectId`, before `mapstructure` ever runs. `mockLegacyCliConfig` + // defaults the resolved ref to `LEGACY_VALID_REF`. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "base-value" + +[remotes.staging] +project_id = "${LEGACY_VALID_REF}" + +[remotes.staging.edge_runtime.secrets] +FROM_CONFIG = "remote-value" +`, + ); + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "remote-value" }, + ]); + expect(out.stderrText).toContain("Loading config override: [remotes.staging]\n"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not print a remote override notice when no [remotes.*] block matches the resolved ref", + () => { + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" +`, + ); + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "config-value" }, + ]); + expect(out.stderrText).not.toContain("Loading config override"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "tolerates two [remotes.*] blocks sharing the target project_id, logs it, and still sets CLI-arg secrets (CLI-1867 Go parity)", + () => { + // Go's `flags.LoadConfig` swallows *any* `Load()` error non-fatally + // (`internal/secrets/set/set.go:22-24`), including the duplicate- + // `project_id` error `loadFromFile` raises before `mapstructure` ever + // runs (`pkg/config/config.go:601`). There is no parsed document to + // recover a subtree from, so config-sourced secrets are dropped + // entirely — only CLI-arg secrets survive. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" + +[remotes.a] +project_id = "dupe-project-id" + +[remotes.b] +project_id = "dupe-project-id" +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("duplicate project_id for [remotes."); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "tolerates a [remotes.*] block with a malformed project_id and still sets CLI-arg secrets (Go parity)", + () => { + // Go's `flags.LoadConfig` swallows *any* `Load()` error non-fatally + // (`internal/secrets/set/set.go:22-24`), including the invalid-format + // error `Config.Validate` raises for every `[remotes.*].project_id` + // that doesn't match Go's ref pattern (`pkg/config/config.go:996-1001`), + // which runs inside the same `Config.Load()` call (`config.go:882`) as + // the duplicate check above. There is no parsed document to recover a + // subtree from, so config-sourced secrets are dropped entirely — only + // CLI-arg secrets survive. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" + +[remotes.a] +project_id = "not-a-valid-ref" +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("Invalid config for remotes.a.project_id"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not echo a literal secret value from config.toml into the debug log on a syntax error", + () => { + // `smol-toml`'s `TomlError` embeds a source codeblock (the offending line ±1) + // in its message; the planted secret sits directly above the syntax error so + // it would land inside that codeblock if the handler logged the raw message. + writeConfig( + [ + "[edge_runtime.secrets]", + 'PLANTED_SECRET = "sk_live_TOTALLY_REAL_SECRET_VALUE"', + "BROKEN = = invalid[[[", + ].join("\n"), + ); + const { layer, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).not.toContain("PLANTED_SECRET"); + expect(debugLogger.messages[0]).not.toContain("sk_live_TOTALLY_REAL_SECRET_VALUE"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not echo a literal secret value from config.toml into the debug log on a schema-decode error", + () => { + // Unlike the syntax-error case above, a schema-decode failure has no + // blank-line-separated source codeblock to truncate: Effect's decode + // error puts the rejected value inline on one line (e.g. `Expected + // string, actual ["sk_live_TOTALLY_REAL_SECRET_VALUE"]`). The bad entry + // sits inside `[edge_runtime.secrets]` itself, so this also exercises + // the per-entry recovery path — `PLANTED_SECRET` is dropped, but the + // CLI-arg secret still goes through. + writeConfig( + `[edge_runtime.secrets] +PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).not.toContain("PLANTED_SECRET"); + expect(debugLogger.messages[0]).not.toContain("sk_live_TOTALLY_REAL_SECRET_VALUE"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "still fails with LegacySecretsNoArgumentsError when a malformed config leaves zero secret sources", + () => { + writeConfig("this is not valid = = toml [[[\n"); + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySecretsNoArgumentsError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); it.live("fails with LegacySecretsSetNetworkError on transport failure", () => { const { layer } = setup({ network: "fail" }); diff --git a/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md index 504a4d99f1..873a57cf6d 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md @@ -33,7 +33,6 @@ | `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:` (Go parity — 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`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md index 67676ca81d..e884f1f397 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md @@ -7,12 +7,13 @@ stack is used; with `--linked` the remote project is used. ## Files Read -| Path | Format | When | -| ---------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | -| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/` (Go `config.go:757-759`), an absolute path is used as-is | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing (Go `config.go:845-861`) — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | +| Path | Format | When | +| --------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | +| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/` (Go `config.go:757-759`), an absolute path is used as-is | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing (Go `config.go:845-861`) — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | +| `/supabase/.env*`, `/.env*` | dotenv | when no pre-resolved `yes` is passed in (the standalone command; `db reset --local` passes its own), to resolve `SUPABASE_YES` for the overwrite/prune prompts (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -71,6 +72,7 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when | `DOCKER_HOST` | when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | | `SUPABASE_AUTH_SERVICE_ROLE_KEY` | when set and non-empty: for `--linked`, used as the service-role key (skips Management API key fetch); for local runs, used as the service-role key instead of `auth.service_role_key` (Go Viper AutomaticEnv parity) | no | | `SUPABASE_AUTH_JWT_SECRET` | local runs only: when set and non-empty, overrides `auth.jwt_secret` for service-role key derivation (Go Viper `AutomaticEnv`+`SUPABASE_` prefix parity, `config.go:492-497`) | no | +| `SUPABASE_YES` | auto-confirms the overwrite/prune prompts, same as `--yes`; read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878) | no | ## Exit Codes @@ -114,6 +116,7 @@ Creating vector bucket: Pruning vector bucket: Uploading: / => / Skipping non-regular file: +Skipping OS metadata file: WARNING: Vector buckets are not available in this project's region yet. Skipping vector bucket seeding. 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. ``` diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 9ddb8fdb86..f5d04275b7 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -1,138 +1,31 @@ -import { - loadProjectConfig, - type LoadProjectConfigOptions, - ProjectConfigSchema, -} from "@supabase/config"; -import { Effect, FileSystem, Option, Path, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import type { PlatformError } from "effect/PlatformError"; +import { Effect, Option } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; -import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { - legacyResolveStorageCredentials, - legacyStorageGatewayFetch, -} from "../../../shared/legacy-storage-credentials.ts"; -import { - legacyParseFileSizeLimit, - legacyResolveBucketProps, -} from "../../../shared/legacy-storage-bucket-config.ts"; -import { - type LegacyStorageGateway, - type LegacyUpsertBucketProps, - legacyMakeStorageGateway, -} from "../../../shared/legacy-storage-gateway.ts"; -import type { LegacyStorageGatewayError } from "../../../shared/legacy-storage-gateway.errors.ts"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { - legacyIsLocalVectorBucketsUnavailable, - legacyIsVectorBucketsFeatureNotEnabled, -} from "./buckets.classify.ts"; -import { LegacySeedConfigLoadError } from "./buckets.errors.ts"; -import { legacyBucketObjectKey } from "./buckets.upload.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; -import { - legacyContentTypeForUpload, - legacyReadSniffBytes, -} from "../../../shared/legacy-storage-content-type.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; -const CONFIG_PATH = "supabase/config.toml"; -const UPLOAD_CONCURRENCY = 5; - -/** - * Mirrors Go's `ValidateBucketName` regex (`apps/cli-go/pkg/config/config.go:1382`). - * Used to validate `[storage.buckets]` names before any Storage API call, matching - * Go's config-load-time check (`config.go:899-903`). Vector and analytics names are - * NOT validated here — Go only validates `[storage.buckets]`. - */ -const LEGACY_BUCKET_NAME_PATTERN = /^(?:[0-9A-Za-z_]|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; - -/** - * Verbatim Go regex literal (`config.go:1382`) — used in the error message so it - * is byte-identical to Go's output. Do NOT derive from `LEGACY_BUCKET_NAME_PATTERN.source`. - */ -const LEGACY_BUCKET_NAME_PATTERN_SOURCE = - "^(\\w|!|-|\\.|\\*|'|\\(|\\)| |&|\\$|@|=|;|:|\\+|,|\\?)*$"; - -const legacyValidateBucketName = Effect.fnUntraced(function* (name: string) { - if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { - return yield* new LegacySeedConfigLoadError({ - message: `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN_SOURCE})`, - }); - } -}); - -interface CollectedFile { - readonly absPath: string; - readonly displayPath: string; -} - -/** Mutable run summary, emitted as the structured result in json/stream-json mode. */ -interface SeedSummary { - readonly buckets_created: Array; - readonly buckets_updated: Array; - readonly buckets_skipped: Array; - readonly vector_created: Array; - readonly vector_pruned: Array; - vector_skipped: boolean; - readonly objects_uploaded: Array; - readonly analytics_created: Array; - readonly analytics_pruned: Array; -} - -function emptySummary(): SeedSummary { - return { - buckets_created: [], - buckets_updated: [], - buckets_skipped: [], - vector_created: [], - vector_pruned: [], - vector_skipped: false, - objects_uploaded: [], - analytics_created: [], - analytics_pruned: [], - }; -} - -/** - * Embedded-default project config, decoded from an empty object — the same - * `decodeUnknownSync(ProjectConfigSchema)({})` the loader uses internally - * (`packages/config/src/io.ts:54-56`). Go's `seed buckets` never aborts on a - * missing `config.toml`: it reads the package-global `utils.Config`, initialized - * to embedded defaults, and `config.Load` no-ops on a missing file. So "no - * config file" behaves like the embedded-default config. - */ -const legacyDecodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); - /** * `supabase seed buckets` — seeds Storage buckets from * `[storage.buckets]` / `[storage.vector]` in `supabase/config.toml`. * * Port of `apps/cli-go/internal/seed/buckets/buckets.go`. When `--linked` is * passed, the remote Storage gateway is used with the project's service-role key; - * otherwise the local stack is used. + * otherwise the local stack is used. The seeding work lives in the hoisted + * `legacySeedBucketsRun` (shared with `db reset --local`); this handler owns the + * target-flag resolution and the post-run cache + telemetry side effects. */ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( // Target is selected from the changed-flag set (Go's flag.Changed), not the // parsed value, so the flags arg itself is unused here. _flags: LegacyBucketsFlags, ) { - const output = yield* Output; - const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const cliArgs = yield* CliArgs; - // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). - const yes = yield* legacyResolveYes; // Set once --linked resolves a ref; drives the post-run linked-project cache // write + org/project group identify, mirroring Go's `ensureProjectGroupsCached` @@ -141,11 +34,11 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { - // 1. Resolve the project ref for --linked BEFORE loading config, so that - // the matching `[remotes.]` override (whose `project_id == ref`) is - // merged over the base config by `loadProjectConfig`. Go selects the target - // from `flag.Changed`, not the flag value: `--linked` is the linked path - // whenever it's *set* (even `--linked=false`). + // Resolve the project ref for --linked BEFORE loading config, so that the + // matching `[remotes.]` override (whose `project_id == ref`) is merged + // over the base config by `loadProjectConfig`. Go selects the target from + // `flag.Changed`, not the flag value: `--linked` is the linked path whenever + // it's *set* (even `--linked=false`). const setFlags = legacySeedChangedTargetFlags(cliArgs.args); const projectRefResolver = yield* LegacyProjectRefResolver; const projectRef = setFlags.includes("linked") @@ -153,123 +46,7 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( : ""; linkedRef = projectRef; - // 2. Load config.toml, passing projectRef so `[remotes.*]` overrides are - // merged for --linked. A parse failure aborts before any network call. - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; - const loaded = yield* loadProjectConfig(cliConfig.workdir, loadOptions).pipe( - Effect.catchTag( - "ProjectConfigParseError", - (cause) => - new LegacySeedConfigLoadError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, - }), - ), - ); - // A missing config file is NOT an early exit: Go uses embedded defaults and - // still gates the no-op on `len(projectRef) == 0`. So local + no-config falls - // into the no-op short-circuit; `--linked` + no-config falls through to the - // remote path so auth/project/API failures surface. - const config = loaded === null ? legacyDecodeDefaultProjectConfig({}) : loaded.config; - const document = loaded === null ? undefined : loaded.document; - - // Go prints this from inside config load (`config.go:513`) 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 bucketsConfig = config.storage.buckets ?? {}; - const bucketNames = Object.keys(bucketsConfig); - const vectorEnabled = config.storage.vector.enabled; - const vectorBucketNames = Object.keys(config.storage.vector.buckets); - const hasVectorBuckets = vectorBucketNames.length > 0; - - // 3. Config-load-time validations run BEFORE the no-op short-circuit: Go - // decodes the whole config (storage.FileSizeLimit, bucket sizes) and runs - // ValidateBucketName during config.Load — before `buckets.Run` can take its - // no-op path — so an invalid value fails even when there's nothing to seed. - // - // 3a. Bucket names (Go ValidateBucketName, config.go:899-903). - for (const name of bucketNames) { - yield* legacyValidateBucketName(name); - } - - // 3b. Storage-level file_size_limit, parsed unconditionally. - const storageFileSizeLimitBytes = yield* parseFileSizeLimitOrFail( - config.storage.file_size_limit, - ); - - // 3c. Per-bucket props (sizes parsed before any Storage call). - const bucketPropsByName = new Map(); - for (const [name, bucket] of Object.entries(bucketsConfig)) { - bucketPropsByName.set( - name, - yield* computeBucketProps(document, name, bucket, storageFileSizeLimitBytes), - ); - } - - // 3d. Short-circuit: nothing to seed (ref present → never short-circuits). - if (projectRef === "" && bucketNames.length === 0 && !hasVectorBuckets) { - if (output.format !== "text") { - yield* output.success("", { ...emptySummary() }); - } - return; - } - - // 4. Build the Storage service-gateway client (local or remote). - const credentials = yield* legacyResolveStorageCredentials({ projectRef, config }); - - // All gateway operations run with an explicit non-DoH fetch (CA-trusting for - // local + https, plain `globalThis.fetch` otherwise). The api-keys lookup - // inside `legacyResolveStorageCredentials` runs BEFORE this scope, so it - // still honors `--dns-resolver https`, matching Go's `tenant.GetApiKeys`. - const gatewayOps = Effect.gen(function* () { - const gateway = yield* legacyMakeStorageGateway({ - baseUrl: credentials.baseUrl, - apiKey: credentials.apiKey, - userAgent: cliConfig.userAgent, - }); - - const summary = emptySummary(); - - // 5. Upsert configured buckets. - yield* upsertBuckets(output, yes, gateway, bucketPropsByName, summary); - - // 6. Upsert analytics buckets (remote --linked only). - if (config.storage.analytics.enabled && projectRef !== "") { - yield* output.raw("Updating analytics buckets...\n", "stderr"); - yield* upsertAnalyticsBuckets( - output, - yes, - gateway, - Object.keys(config.storage.analytics.buckets), - summary, - ); - } - - // 7. Upsert vector buckets (local), with graceful skip on unavailability. - if (vectorEnabled && hasVectorBuckets) { - yield* output.raw("Updating vector buckets...\n", "stderr"); - yield* upsertVectorBuckets(output, yes, gateway, vectorBucketNames, summary).pipe( - Effect.catch((error) => handleVectorError(output, error, summary)), - ); - } - - // 8. Upload objects for each bucket with a configured objects_path. - yield* uploadObjects(fs, path, output, gateway, cliConfig.workdir, bucketsConfig, summary); - - // 9. Machine-readable summary (Go has none; text mode emits nothing extra). - if (output.format !== "text") { - yield* output.success("", { ...summary }); - } - }); - - yield* gatewayOps.pipe( - Effect.provideService( - FetchHttpClient.Fetch, - legacyStorageGatewayFetch(credentials.localKongCa), - ), - ); + yield* legacySeedBucketsRun({ projectRef, emitSummary: true }); }).pipe( // Go's root `Execute` caches the linked project + fires org/project group // identify whenever `flags.ProjectRef` is set — only on the --linked path. @@ -279,315 +56,3 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( Effect.ensuring(telemetryState.flush), ); }); - -type BucketsConfig = Readonly< - Record< - string, - { - readonly public: boolean; - readonly file_size_limit: string; - readonly allowed_mime_types: ReadonlyArray; - readonly objects_path: string; - } - > ->; - -// Parse a `file_size_limit` string to bytes, mapping a parse failure to a -// config-load error (Go rejects an invalid `sizeInBytes` during `config.Load`, -// before NewStorageAPI). -const parseFileSizeLimitOrFail = (value: string) => - Effect.try({ - try: () => legacyParseFileSizeLimit(value), - catch: (cause) => - new LegacySeedConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - -const computeBucketProps = ( - document: Record | undefined, - name: string, - bucket: BucketsConfig[string], - storageFileSizeLimitBytes: number, -) => - Effect.try({ - try: () => legacyResolveBucketProps({ document, name, bucket, storageFileSizeLimitBytes }), - catch: (cause) => - new LegacySeedConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - -// Port of `pkg/storage/batch.go:UpsertBuckets`. `propsByName` is precomputed and -// size-validated before this runs (Go parses sizes at config-load, before any -// Storage call). -const upsertBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - 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) { - const bucketId = byName.get(name); - if (bucketId !== undefined) { - const overwrite = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(bucketId)} already exists. Do you want to overwrite its properties?`, - true, - ); - if (!overwrite) { - summary.buckets_skipped.push(bucketId); - continue; - } - yield* output.raw(`Updating Storage bucket: ${bucketId}\n`, "stderr"); - yield* gateway.updateBucket(bucketId, props); - summary.buckets_updated.push(bucketId); - } else { - yield* output.raw(`Creating Storage bucket: ${name}\n`, "stderr"); - yield* gateway.createBucket(name, props); - summary.buckets_created.push(name); - } - } -}); - -// Port of `pkg/storage/vector.go:UpsertVectorBuckets`. -const upsertVectorBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - configuredNames: ReadonlyArray, - summary: SeedSummary, -) { - const existing = yield* gateway.listVectorBuckets(); - const existingSet = new Set(existing); - const configuredSet = new Set(configuredNames); - const toDelete = existing.filter((name) => !configuredSet.has(name)); - - for (const name of configuredNames) { - if (existingSet.has(name)) { - yield* output.raw(`Bucket already exists: ${name}\n`, "stderr"); - continue; - } - yield* output.raw(`Creating vector bucket: ${name}\n`, "stderr"); - yield* gateway.createVectorBucket(name); - summary.vector_created.push(name); - } - - for (const name of toDelete) { - const prune = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, - false, - ); - if (!prune) { - continue; - } - yield* output.raw(`Pruning vector bucket: ${name}\n`, "stderr"); - yield* gateway.deleteVectorBucket(name); - summary.vector_pruned.push(name); - } -}); - -// Port of `pkg/storage/analytics.go:UpsertAnalyticsBuckets`. -const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - configuredNames: ReadonlyArray, - summary: SeedSummary, -) { - const existing = yield* gateway.listAnalyticsBuckets(); - const existingSet = new Set(existing); - const configuredSet = new Set(configuredNames); - const toDelete = existing.filter((name) => !configuredSet.has(name)); - - for (const name of configuredNames) { - if (existingSet.has(name)) { - yield* output.raw(`Bucket already exists: ${name}\n`, "stderr"); - continue; - } - yield* output.raw(`Creating analytics bucket: ${name}\n`, "stderr"); - yield* gateway.createAnalyticsBucket(name); - summary.analytics_created.push(name); - } - - for (const name of toDelete) { - const prune = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, - false, - ); - if (!prune) { - continue; - } - yield* output.raw(`Pruning analytics bucket: ${name}\n`, "stderr"); - yield* gateway.deleteAnalyticsBucket(name); - summary.analytics_pruned.push(name); - } -}); - -/** - * Vector graceful-skip (`buckets.go:57-66`): on `FeatureNotEnabled` / - * local-unavailable errors, print the matching WARNING and continue (object - * upload still runs). Any other error propagates. - */ -const handleVectorError = Effect.fnUntraced(function* ( - output: typeof Output.Service, - error: LegacyStorageGatewayError, - summary: SeedSummary, -) { - if (legacyIsVectorBucketsFeatureNotEnabled(error.message)) { - yield* output.raw( - `${legacyYellow("WARNING:")} Vector buckets are not available in this project's region yet. Skipping vector bucket seeding.\n`, - "stderr", - ); - summary.vector_skipped = true; - return; - } - if (legacyIsLocalVectorBucketsUnavailable(error.message)) { - yield* output.raw( - `${legacyYellow("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.\n`, - "stderr", - ); - summary.vector_skipped = true; - return; - } - return yield* Effect.fail(error); -}); - -// Port of `pkg/storage/batch.go:UpsertObjects` (+ object walk in objects.go). -const uploadObjects = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - gateway: LegacyStorageGateway, - workdir: string, - bucketsConfig: BucketsConfig, - summary: SeedSummary, -) { - for (const [name, bucket] of Object.entries(bucketsConfig)) { - const objectsPath = bucket.objects_path; - if (objectsPath.length === 0) { - continue; - } - // Go resolves a relative bucket objects_path against SupabaseDirPath at - // config-resolve time (`pkg/config/config.go:757-759`); absolute paths are - // left untouched. `displayRoot` (workdir-relative) drives the `Uploading:` - // stderr and the destination key so both stay byte-identical to Go. - const displayRoot = path.isAbsolute(objectsPath) - ? objectsPath - : path.join("supabase", objectsPath); - const absRoot = path.isAbsolute(objectsPath) - ? objectsPath - : path.join(workdir, "supabase", objectsPath); - const files = yield* collectFiles(fs, path, output, absRoot, displayRoot); - yield* Effect.forEach( - files, - (file) => - Effect.gen(function* () { - const dstPath = legacyBucketObjectKey(name, displayRoot, file.displayPath); - yield* output.raw(`Uploading: ${file.displayPath} => ${dstPath}\n`, "stderr"); - // Content-type is byte-driven: Go sniffs the first 512 bytes with - // http.DetectContentType, refining only a generic text/plain by - // extension (`pkg/storage/objects.go:77-108`). - const sniff = yield* legacyReadSniffBytes(fs, file.absPath); - // Go's seed upload always sets Cache-Control max-age=3600 and x-upsert - // (Overwrite) true (`pkg/storage/batch.go`). - yield* gateway.uploadObject(dstPath, file.absPath, { - contentType: legacyContentTypeForUpload(sniff, file.absPath), - cacheControl: "max-age=3600", - overwrite: true, - }); - summary.objects_uploaded.push(dstPath); - }), - { concurrency: UPLOAD_CONCURRENCY }, - ); - } -}); - -/** - * Collect uploadable files under `absRoot`, lexically ordered, mirroring Go's - * `fs.WalkDir` + `isUploadableEntry` (`pkg/storage/batch.go:65-131`). - * - * Parity details: - * - The **root** is resolved with a following stat (Go's `fs.Stat`), so a - * symlinked `objects_path` is followed; a missing/dangling root fails. - * - **Nested** entries use no-follow detection: real directories are descended; - * symlinks are NOT descended — Go's `isUploadableEntry` OPENS the symlink - * target then stats the handle, uploading only a regular file and skipping - * dangling symlinks / symlinks-to-directories / unreadable targets. - */ -const collectFiles = ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - absRoot: string, - displayRoot: string, -): Effect.Effect, PlatformError> => - Effect.gen(function* () { - const info = yield* fs.stat(absRoot); - if (info.type === "Directory") { - return yield* collectDir(fs, path, output, absRoot, displayRoot); - } - if (info.type === "File") { - return [{ absPath: absRoot, displayPath: displayRoot }]; - } - yield* output.raw(`Skipping non-regular file: ${displayRoot}\n`, "stderr"); - return []; - }); - -const collectDir = ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - absDir: string, - displayDir: string, -): Effect.Effect, PlatformError> => - Effect.gen(function* () { - const names = [...(yield* fs.readDirectory(absDir))].sort(); - const collected: Array = []; - for (const name of names) { - const absChild = path.join(absDir, name); - const displayChild = path.join(displayDir, name); - // `readLink` succeeds only on a symlink — our no-follow detector (Effect's - // `stat` follows symlinks and has no `lstat`). - const isSymlink = yield* fs.readLink(absChild).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ); - if (isSymlink) { - // Go `isUploadableEntry` (batch.go:73-84) OPENS the target then stats the - // handle; it uploads only a regular file. `stat` alone would queue an - // unreadable target and abort later at upload, so mirror Go: open + stat. - const targetType = yield* Effect.scoped( - Effect.gen(function* () { - const handle = yield* fs.open(absChild, { flag: "r" }); - const targetInfo = yield* handle.stat; - return targetInfo.type; - }), - ).pipe(Effect.catch(() => Effect.succeed("Unknown" as const))); - if (targetType === "File") { - collected.push({ absPath: absChild, displayPath: displayChild }); - } else { - yield* output.raw(`Skipping non-regular file: ${displayChild}\n`, "stderr"); - } - continue; - } - const childInfo = yield* fs.stat(absChild); - if (childInfo.type === "Directory") { - collected.push(...(yield* collectDir(fs, path, output, absChild, displayChild))); - } else if (childInfo.type === "File") { - collected.push({ absPath: absChild, displayPath: displayChild }); - } else { - yield* output.raw(`Skipping non-regular file: ${displayChild}\n`, "stderr"); - } - } - return collected; - }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index 986fcc4af6..a689e82bfb 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -25,6 +25,7 @@ import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectRefResolver } from "../../../../legacy/config/legacy-project-ref.service.ts"; import { LegacyProjectNotLinkedError } from "../../../../legacy/config/legacy-project-ref.errors.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import { legacySeedBuckets } from "./buckets.handler.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; import { LegacyPlatformApi } from "../../../../legacy/auth/legacy-platform-api.service.ts"; @@ -323,6 +324,36 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "honors a piped decline for the overwrite prompt when non-interactive (db reset path)", + () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "test", id: "test" }] }, + ], + pipedAnswers: ["n"], + }); + return Effect.gen(function* () { + // db reset seeds buckets with interactive=false (Go's `buckets.Run(ctx, "", + // false, fsys)` forces console.IsTTY=false). Go does NOT silently take the + // default: the prompt still prints its label, scans one line, and honors a + // parsed answer — so the piped "n" must skip the overwrite (default is yes). + const exit = yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain( + "already exists. Do you want to overwrite its properties?", + ); + expect(out.stderrText).not.toContain("Updating Storage bucket"); + expect(requests.some((r) => r.method === "PUT")).toBe(false); + }); + }, + ); + it.live("creates configured vector buckets and leaves stale ones (prune default no)", () => { const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n[storage.vector.buckets.existing-vec]\n", @@ -397,6 +428,40 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "prunes a stale vector bucket when the caller passes yes (db reset project-env path)", + () => { + // `db reset` resolves `yes` with the nested project `.env` and passes it into the runner + // with `interactive: false`; the pre-resolved `yes` must drive pruning even though no + // prompt is answered and the runner's own shell-only resolveYes would default to false. + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.keep-vec]\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { + method: "POST", + match: VECTOR_LIST, + body: { + vectorBuckets: [{ vectorBucketName: "keep-vec" }, { vectorBucketName: "stale-vec" }], + }, + }, + { method: "POST", match: VECTOR_DELETE, body: {} }, + ], + // No `confirm` and no `--yes` flag — pruning is driven solely by the passed `yes`. + }); + return Effect.gen(function* () { + yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + yes: true, + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Pruning vector bucket: stale-vec"); + expect(requests.some((r) => r.url.includes(VECTOR_DELETE))).toBe(true); + }); + }, + ); + it.live("warns and continues when vector buckets are unavailable in the region", () => { const { layer, out } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", @@ -1220,6 +1285,95 @@ describe("legacy seed buckets", () => { }); }); + it.live("skips OS metadata files during the object walk (CLI-1950)", () => { + // .DS_Store (macOS Finder), Thumbs.db and desktop.ini (Windows Explorer) + // must never be uploaded as seeded objects — they are never even attempted, + // covering the "silently becomes a public object" failure mode, not just + // an upload-time abort. + mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); + writeFileSync(join(tmp.current, "supabase", "assets", ".DS_Store"), "junk"); + writeFileSync(join(tmp.current, "supabase", "assets", "Thumbs.db"), "junk"); + writeFileSync(join(tmp.current, "supabase", "assets", "desktop.ini"), "junk"); + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/object/", body: {} }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Skipping OS metadata file: supabase/assets/.DS_Store"); + expect(out.stderrText).toContain("Skipping OS metadata file: supabase/assets/Thumbs.db"); + expect(out.stderrText).toContain("Skipping OS metadata file: supabase/assets/desktop.ini"); + expect(out.stderrText).toContain("Uploading: supabase/assets/a.txt => images/a.txt"); + const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); + expect(uploads).toHaveLength(1); + }); + }); + + it.live( + "skips a .DS_Store file in a MIME-restricted bucket instead of uploading it (CLI-1950)", + () => { + // Reproduces the original bug report shape: a bucket with allowed_mime_types + // restricted to images. The test harness's mock HTTP route doesn't enforce + // allowed_mime_types server-side (that's real Storage-service behavior), so + // this doesn't simulate the 415 abort itself — it asserts the junk file is + // skipped and never uploaded, while the real image file still uploads. + mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "assets", "logo.png"), "fake-png-bytes"); + writeFileSync(join(tmp.current, "supabase", "assets", ".DS_Store"), "junk"); + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: [ + "[storage.buckets.images]", + "public = true", + 'allowed_mime_types = ["image/png"]', + 'objects_path = "./assets"', + ].join("\n"), + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/object/", body: {} }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Skipping OS metadata file: supabase/assets/.DS_Store"); + expect(out.stderrText).toContain("Uploading: supabase/assets/logo.png => images/logo.png"); + const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); + expect(uploads).toHaveLength(1); + }); + }, + ); + + it.live("skips a .DS_Store file when objects_path points directly at it (CLI-1950)", () => { + // Covers collectFiles' single-file branch: objects_path resolves directly to + // a junk-named file rather than a directory. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".DS_Store"), "junk"); + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./.DS_Store"\n', + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Skipping OS metadata file: supabase/.DS_Store"); + const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); + expect(uploads).toHaveLength(0); + }); + }); + // Root bypasses POSIX permission bits, so chmod 000 wouldn't block open() there // and the open-vs-stat distinction this test relies on would vanish. const isRoot = typeof process.getuid === "function" && process.getuid() === 0; @@ -1346,6 +1500,37 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "auto-confirms the overwrite from SUPABASE_YES in the project .env (Go loadNestedEnv, CLI-1878)", + () => { + // SUPABASE_YES lives only in supabase/.env, not the shell or the --yes flag. `seed + // buckets` defaults to `--local` (Go: `cmd/seed.go:31`), and root's + // `ParseDatabaseConfig` loads the project `.env` files before `buckets.Run`'s + // overwrite prompt (`root.go:118`), so the standalone command's own fallback + // resolution (not the `db reset`-passed `opts.yes`) must read it too. + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.assets]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "assets", id: "assets" }] }, + { method: "PUT", match: "/storage/v1/bucket/assets", body: {} }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain( + "already exists. Do you want to overwrite its properties? [Y/n] y", + ); + expect(out.stderrText).toContain("Updating Storage bucket: assets"); + expect(requests.some((r) => r.method === "PUT")).toBe(true); + }); + }, + ); + it.live("--yes prunes a stale vector bucket and echoes Go's prompt line", () => { const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.vec1]\n", diff --git a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md index 07092ca913..092f927b61 100644 --- a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md @@ -16,12 +16,22 @@ ## API Routes -The resolved project ref must match `^[a-z]{20}$` (Go's `utils.ProjectRefPattern`) -before any remote lookup runs; a malformed ref skips the linked-version checks -and only the local matrix is printed. Tenant calls send `apikey: ` -and additionally `Authorization: Bearer ` unless the key is a -new-style `sb_…` key (which authenticates via the `apikey` header alone), -matching `apps/cli-go/pkg/fetcher/gateway.go`. +**Divergence from Go on a malformed ref:** Go validates the resolved ref against +`utils.ProjectRefPattern` (`^[a-z]{20}$`) but only warns on failure +(`cmd/services.go`'s `Run` prints the validation error to stderr) and still +calls `listRemoteImages` with the malformed ref anyway (`services.go:61-62`). +TS prints the same warning ("Invalid project ref format. Must be like +`abcdefghijklmnopqrst`.") but deliberately skips the remote lookup instead of +reproducing Go's behavior — the ref is embedded unescaped into the tenant +gateway hostname below, so proceeding with a malformed value would let it +redirect the service-role key to an attacker-controlled host. Only the local +matrix is printed in this case. This is intentional TS-only hardening, not a +parity bug. + +Tenant calls send `apikey: ` and additionally +`Authorization: Bearer ` unless the key is a new-style `sb_…` key +(which authenticates via the `apikey` header alone), matching +`apps/cli-go/pkg/fetcher/gateway.go`. | Method | Path | Auth | Request body | Response (used fields) | | ------ | ---------------------------------------------- | ------------------------------ | ------------ | ------------------------------------------------------------------ | @@ -36,7 +46,7 @@ matching `apps/cli-go/pkg/fetcher/gateway.go`. | Variable | Purpose | Required? | | ----------------------- | --------------------------------------------------- | ----------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes @@ -75,5 +85,6 @@ TS-only NDJSON success event with the same `{ services: [...] }` payload. - Local versions come from the command's baked-in service matrix; the command does not inspect Docker state or local config files. - Linked-version checks are best-effort. Remote lookup failures do not change the exit code; they only leave the `LINKED` column empty for unavailable services. +- A malformed linked ref is the one lookup failure that prints an explicit stderr warning (see API Routes above); every other remote failure (network error, expired token, etc.) still fails silently and just leaves `LINKED` empty. Most real-world malformed refs come from an untrimmed `SUPABASE_PROJECT_ID` env var (e.g. a trailing newline from a secrets manager or `.env` file) rather than actual file tampering — the env var is read raw and unlike the on-disk `project-ref` file is never trimmed, matching Go's own `viper.GetString("PROJECT_ID")` (`internal/utils/flags/project_ref.go:62`). - Version mismatches are reported to stderr as a warning. - `telemetry.json` is written on every invocation, including `--output env` failures, to match the legacy Go command lifecycle. diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index f6dd731220..7fa9a9e81b 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -1,12 +1,16 @@ import { Effect, Exit, FileSystem, Option, Path } from "effect"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; +import { + INVALID_PROJECT_REF_MESSAGE, + PROJECT_REF_PATTERN, +} from "../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; import { legacyReadDbToml } from "../../shared/legacy-db-config.toml-read.ts"; import { legacyResolveDbImage } from "../../shared/legacy-db-image.ts"; import { legacyResolveEdgeRuntimeImage } from "../../shared/legacy-edge-runtime-image.ts"; -import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; +import { legacyReadServiceVersionOverrides } from "../../shared/legacy-service-version-overrides.ts"; import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeGoJson, encodeToml, encodeYaml } from "../../shared/legacy-go-output.encoders.ts"; @@ -16,8 +20,6 @@ import { formatServicesWarning, listLocalServiceVersions, type LocalServiceImageOverrides, - type LocalServiceVersionName, - type LocalServiceVersionOverrides, mergeRemoteServiceVersions, renderServicesTable, renderServicesWarning, @@ -62,6 +64,21 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le yield* Effect.gen(function* () { const accessTokenExit = yield* credentials.getAccessToken.pipe(Effect.exit); const accessToken = Exit.isSuccess(accessTokenExit) ? accessTokenExit.value : Option.none(); + + const validLinkedRef = Option.filter(linkedProjectRef, (ref) => PROJECT_REF_PATTERN.test(ref)); + if (Option.isSome(linkedProjectRef) && Option.isNone(validLinkedRef)) { + // Go's `flags.LoadProjectRef` (project_ref.go:54-76) validates the ref but + // `cmd/services.go`'s Run only warns on the error and keeps going, so Go + // still calls `listRemoteImages` with the malformed ref (services.go:61-62). + // TS matches the warning but deliberately skips the remote call instead of + // reproducing it: the ref is embedded unescaped into the tenant gateway + // hostname in `fetchLinkedServiceVersions`, so proceeding would let a + // malformed ref redirect the service-role key to an attacker-controlled host. + // Emitted before the config-load warning below to match the order Go's + // `Run` prints them in (services.go:18-24). + yield* output.raw(`${INVALID_PROJECT_REF_MESSAGE}\n`, "stderr"); + } + const tomlValues = yield* legacyReadDbToml( fs, path, @@ -75,7 +92,7 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le const serviceVersions = tomlValues === null ? {} - : yield* readLegacyServiceVersionOverrides( + : yield* legacyReadServiceVersionOverrides( fs, path, cliConfig.workdir, @@ -109,11 +126,11 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le }; let rows = listLocalServiceVersions(localImageOptions); - if (Option.isSome(linkedProjectRef) && Option.isSome(accessToken)) { + if (Option.isSome(validLinkedRef) && Option.isSome(accessToken)) { const remote = yield* fetchLinkedServiceVersions({ apiUrl: cliConfig.apiUrl, projectHost: cliConfig.projectHost, - projectRef: linkedProjectRef.value, + projectRef: validLinkedRef.value, accessToken: accessToken.value, userAgent: cliConfig.userAgent, }); @@ -166,42 +183,3 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le function formatConfigLoadError(error: unknown): string { return error instanceof Error ? error.message : String(error); } - -const LEGACY_VERSION_FILES = [ - ["auth", "gotrue-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14], - ["postgrest", "rest-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14], - ["storage", "storage-version"], - ["realtime", "realtime-version"], - ["studio", "studio-version"], - ["pgmeta", "pgmeta-version"], - ["analytics", "logflare-version"], - ["pooler", "pooler-version"], -] as const satisfies ReadonlyArray< - readonly [LocalServiceVersionName, string, ((majorVersion: number | undefined) => boolean)?] ->; - -const readLegacyServiceVersionOverrides = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - majorVersion: number | undefined, -) { - const paths = legacyTempPaths(path, workdir); - const versions: LocalServiceVersionOverrides = {}; - - for (const [service, fileName, shouldRead] of LEGACY_VERSION_FILES) { - if (shouldRead !== undefined && !shouldRead(majorVersion)) { - continue; - } - - const version = yield* fs.readFileString(path.join(paths.tempDir, fileName)).pipe( - Effect.map((content) => content.trim()), - Effect.orElseSucceed(() => ""), - ); - if (version.length > 0) { - versions[service] = version; - } - } - - return versions; -}); diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 7e03294354..86c2ceb807 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -5,10 +5,11 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { CliOutput, Command } from "effect/unstable/cli"; import { Stdio } from "effect"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { INVALID_PROJECT_REF_MESSAGE } from "../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; import { @@ -45,6 +46,8 @@ function setup( format?: "text" | "json" | "stream-json"; goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; workdir?: string; + accessToken?: string; + apiUrl?: string; } = {}, ) { const out = mockOutput({ @@ -68,16 +71,20 @@ function setup( LegacyCliConfig, LegacyCliConfig.of({ profile: "supabase", - apiUrl: "https://api.supabase.com", + apiUrl: opts.apiUrl ?? "https://api.supabase.com", projectHost: "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: Option.none(), projectId: Option.none(), workdir: opts.workdir ?? process.cwd(), userAgent: "SupabaseCLI/test", }), ), - Layer.succeed(LegacyCredentials, LegacyCredentials.of(legacyCredentialsMock)), + Layer.succeed( + LegacyCredentials, + LegacyCredentials.of(legacyCredentialsMock(opts.accessToken)), + ), Layer.succeed( LegacyLinkedProjectCache, LegacyLinkedProjectCache.of({ @@ -91,13 +98,19 @@ function setup( }; } -const legacyCredentialsMock = { - getAccessToken: Effect.succeed(Option.none()), - saveAccessToken: () => Effect.die("unexpected saveAccessToken"), - deleteAccessToken: Effect.die("unexpected deleteAccessToken"), - deleteAllProjectCredentials: Effect.void, - deleteProjectCredential: () => Effect.succeed(false), -}; +function legacyCredentialsMock(accessToken?: string) { + return { + getAccessToken: Effect.succeed( + accessToken === undefined + ? Option.none() + : Option.some(Redacted.make(accessToken, { label: "SUPABASE_ACCESS_TOKEN" })), + ), + saveAccessToken: () => Effect.die("unexpected saveAccessToken"), + deleteAccessToken: Effect.die("unexpected deleteAccessToken"), + deleteAllProjectCredentials: Effect.void, + deleteProjectCredential: () => Effect.succeed(false), + }; +} const legacyTestRoot = Command.make("supabase").pipe( Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), @@ -309,6 +322,112 @@ major_version = 15 }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); }); + it.live("warns and skips the remote lookup for a malformed linked project ref", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); + writeTempFile(workdir, "project-ref", "not-a-valid-ref"); + const { layer, out } = setup({ workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain(INVALID_PROJECT_REF_MESSAGE); + expect(out.stdoutText).toContain("supabase/postgres"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + // A token present doesn't bypass the format guard (Go's warning is + // unconditional on login too) — same code path as the previous test, so this + // isn't new branch coverage, just pinning that login state can't skip it. + it.live("still warns on a malformed ref even when logged in", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); + writeTempFile(workdir, "project-ref", "not-a-valid-ref"); + const { layer, out } = setup({ workdir, accessToken: "sbp_test-token" }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain(INVALID_PROJECT_REF_MESSAGE); + expect(out.stdoutText).toContain("supabase/postgres"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("fetches and merges remote versions for a valid ref when logged in", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); + writeTempFile(workdir, "project-ref", "abcdefghijklmnopqrst"); + + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/v1/projects/abcdefghijklmnopqrst") { + return Response.json({ + id: "abcdefghijklmnopqrst", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } + + if (url.pathname === "/v1/projects/abcdefghijklmnopqrst/api-keys") { + // Deliberately no service-role key: this test only needs to prove the + // handler wires the fetch+merge branch through, not re-test + // `fetchLinkedServiceVersions`'s own tenant-probe logic (already + // covered in services.shared.unit.test.ts). Omitting the + // service-role key keeps this test free of a second, tenant-gateway + // mock without weakening the assertion below. + return Response.json([ + { + name: "anon", + id: "publishable-id", + type: "publishable", + api_key: "publishable-key", + description: null, + }, + ]); + } + + return new Response("not found", { status: 404 }); + }, + }); + + const { layer, out } = setup({ + workdir, + accessToken: "sbp_test-token", + apiUrl: server.url.origin, + goOutput: Option.some("json"), + }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stderrText).not.toContain(INVALID_PROJECT_REF_MESSAGE); + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ name: "supabase/postgres", remote: "17.6.1.200" }), + ); + }).pipe( + Effect.ensuring( + Effect.promise(() => server.stop(true)).pipe( + Effect.andThen(Effect.sync(() => rmSync(workdir, { recursive: true, force: true }))), + ), + ), + ); + }); + it.live("reports pinned legacy temp service versions", () => { const workdir = makeProjectWithDbMajorVersion(15); writeTempFile(workdir, "postgres-version", "15.1.0.117\n"); diff --git a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md index 19f3323f9e..ec7b0f9f11 100644 --- a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.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` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written @@ -30,8 +30,7 @@ Only `content.sql` is rendered in text mode. The full payload is exposed via `-- | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md index 867b2331ac..85e3717180 100644 --- a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.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` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written @@ -28,8 +28,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md index ccf0e03d43..d56ed25db2 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md index 6f067ad6b5..2a5657772a 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/sso/add/add.command.ts b/apps/cli/src/legacy/commands/sso/add/add.command.ts index 4030c121a3..e48071520e 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.command.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.command.ts @@ -84,7 +84,16 @@ export const legacySsoAddCommand = Command.make("add", config).pipe( ]), Command.withHandler((flags) => legacySsoAdd(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ + flags, + safeFlags: ["project-ref"], + config, + // `--type` registers `-t` (Flag.withAlias above); without this, `-t saml` + // never resolves to the canonical `type` name in extractChangedFlagNames, + // so it wouldn't appear in telemetry at all (Go's pflag.Visit reports the + // canonical name regardless of shorthand — cmd/root_analytics.go:53-76). + aliases: { t: "type" }, + }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts b/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts index 795b71200a..b2b1636c51 100644 --- a/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts +++ b/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts @@ -69,4 +69,53 @@ describe("supabase sso (legacy)", () => { expect(`${stdout}${stderr}`).toContain(`identity provider ID "not-a-uuid" is not a UUID`); }, ); + + // CLI-1901: `add`'s `--type` has no `Flag.optional` (see `add.command.ts`) + // — Go marks it required via `MarkFlagRequired("type")` (`cmd/sso.go:65`) + // — so a missing/invalid `--type` used to dump the full help doc to + // stdout AND print the error twice on stderr. No auth/network call ever + // happens for either case: flag parsing fails before the handler runs. + // + // A missing required flag and an invalid choice value get different + // treatment, matching the real `apps/cli-go/supabase-go` binary (verified + // directly against it): Go's `PersistentPreRunE` sets `SilenceUsage = true` + // (`cmd/root.go:97`) BEFORE `ValidateRequiredFlags` runs, so a missing + // `--type` is a single clean stderr line with no usage block — but + // `Flag.choice` validation happens during `ParseFlags`, BEFORE that point, + // so Go still shows a usage block for an invalid `--type` value, always on + // stderr, never stdout. + test( + "add without --type: stdout stays clean, stderr is a single Go-parity line (no usage block)", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["sso", "add", "--project-ref", TEST_PROJECT_REF], + { entrypoint: "legacy" }, + ); + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain(`required flag(s) "type" not set`); + expect(stderr).not.toContain("USAGE"); + expect(stderr.trim().split("\n")).toHaveLength(2); + }, + ); + + test( + "add with an invalid --type value: stdout stays clean, the usage content and the single error line land on stderr with no duplicate", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["sso", "add", "--type", "bogus", "--project-ref", TEST_PROJECT_REF], + { entrypoint: "legacy" }, + ); + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("USAGE"); + const occurrences = stderr.split(`Invalid value for flag --type: "bogus"`).length - 1; + expect(occurrences).toBe(1); + expect( + stderr.trim().endsWith("Try rerunning the command with --debug to troubleshoot the error."), + ).toBe(true); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.ts b/apps/cli/src/legacy/commands/sso/update/update.command.ts index cf6732ddca..2f61eecc19 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.command.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.command.ts @@ -106,7 +106,7 @@ export const legacySsoUpdateCommand = Command.make("update", config).pipe( ]), Command.withHandler((flags) => legacySsoUpdate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts index 0afeb0e4d2..6f88558830 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts @@ -47,6 +47,23 @@ describe("legacy sso update domain flags (pflag StringSlice parity)", () => { expect(removeDomains).toEqual(["example.com", "example.org"]); }); + test("--domains= (explicit empty value) parses to an empty array, not a missing flag", async () => { + // Backs the "changed vs truthy" mutex-check fix (CLI-1902): the handler's + // `hasExplicitLongFlag` reads raw argv rather than this parsed value + // precisely because `--domains=` collapses to `[]` here, indistinguishable + // from the flag never being passed at all if you only looked at `.length`. + const [, domains] = await Effect.runPromise( + legacySsoUpdateDomainsFlag + .parse({ + flags: { domains: [""] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(domains).toEqual([]); + }); + test("rejects malformed CSV (bare quote)", async () => { const exit = await Effect.runPromise( legacySsoUpdateDomainsFlag diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index ca86765455..aba1d6833f 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -1,5 +1,5 @@ import type { SupabaseApiError } from "@supabase/api/effect"; -import { Effect, Option, Result } from "effect"; +import { Effect, Option, Result, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -7,6 +7,10 @@ import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts" import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + cobraMutuallyExclusiveErrorMessage, + hasExplicitValueFlag, +} from "../../../../shared/cli/cobra-flag-groups.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { encodeGoJson, @@ -48,6 +52,40 @@ const mapGetStatusOrNetwork = mapLegacyHttpError({ statusMessage: (_status, body) => `unexpected error fetching identity provider: ${body}`, }); +const SSO_UPDATE_COMMAND_PATH = ["sso", "update"] as const; + +/** + * Registration order mirrors Go's `cmd/sso.go:178-180` — three independent + * `MarkFlagsMutuallyExclusive` groups (`metadata-file`/`metadata-url` plus two + * 2-element groups sharing `--domains`, not one 3-way group). Cobra checks + * groups in `sort.Strings`-order of the joined group key (`flag_groups.go:189`), + * which happens to match registration order here: "domains add-domains" < + * "domains remove-domains" < "metadata-file metadata-url" alphabetically. + */ +const SSO_UPDATE_MUTEX_GROUPS = [ + ["domains", "add-domains"], + ["domains", "remove-domains"], + ["metadata-file", "metadata-url"], +] as const; + +/** + * Every value-taking (non-boolean) flag `sso update` declares + * (`update.command.ts`) — tells `hasExplicitValueFlag` which bare tokens + * consume the next argv token as their value. `--skip-url-validation` is this + * command's only boolean flag and is deliberately excluded; booleans never + * consume a following token. + */ +const SSO_UPDATE_VALUE_FLAG_NAMES = new Set([ + "project-ref", + "domains", + "add-domains", + "remove-domains", + "metadata-file", + "metadata-url", + "attribute-mapping-file", + "name-id-format", +]); + const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError) => Effect.gen(function* () { const mapped = yield* Effect.flip(mapGetStatusOrNetwork(cause)); @@ -104,34 +142,50 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; + const stdio = yield* Stdio.Stdio; + const rawArgs = yield* stdio.args; yield* Effect.gen(function* () { + // cobra runs `ValidateFlagGroups` (`command.go:1010`) before `RunE` + // (`command.go:1014`), and Go's provider-ID format check lives inside + // `RunE` (`cmd/sso.go:90-91`) — so a mutex violation must win over an + // invalid provider ID when both apply. Keep this block ahead of + // `validateUuid` below to match that precedence. + // + // "Set" follows cobra's `pflag.Changed` — whether the flag was passed at + // all — not the resulting value. `--domains`/`--add-domains`/ + // `--remove-domains` all default to `[]`, so `--domains=` (parses to an + // empty array) must still count as "set"; gating on `.length > 0` would + // miss it, the same "changed vs truthy" gap CLI-1860 fixed for + // `functions download`'s `--use-docker`. + // + // `hasExplicitValueFlag` (not the simpler `hasExplicitLongFlag`) is + // required here because every flag in these groups takes a value: a bare + // `--metadata-file --metadata-url` is pflag consuming `--metadata-url` as + // `metadata-file`'s (oddly named) value, not two flags being set — see + // that function's doc comment. + for (const group of SSO_UPDATE_MUTEX_GROUPS) { + const changed = group.filter((flagName) => + hasExplicitValueFlag( + rawArgs, + SSO_UPDATE_COMMAND_PATH, + SSO_UPDATE_VALUE_FLAG_NAMES, + flagName, + ), + ); + if (changed.length > 1) { + return yield* Effect.fail( + new LegacySsoMutexFlagError({ + message: cobraMutuallyExclusiveErrorMessage(group, changed), + }), + ); + } + } + const providerId = yield* validateUuid(flags.providerId).pipe( Result.match({ onFailure: Effect.fail, onSuccess: Effect.succeed }), ); - if (flags.domains.length > 0 && flags.addDomains.length > 0) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --domains or --add-domains may be set", - }), - ); - } - if (flags.domains.length > 0 && flags.removeDomains.length > 0) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --domains or --remove-domains may be set", - }), - ); - } - if (Option.isSome(flags.metadataFile) && Option.isSome(flags.metadataUrl)) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --metadata-file or --metadata-url may be set", - }), - ); - } - const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts index c1060092cb..c76206a29b 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts @@ -2,7 +2,7 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Layer, Option, Stdio } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -45,6 +45,13 @@ interface SetupOpts { putStatus?: number; putBody?: unknown; upgradeGate?: "gated" | "notGated"; + /** + * Raw argv the handler sees via `Stdio.Stdio` — drives the + * `hasExplicitLongFlag`-based mutex checks. Defaults to a bare invocation + * with none of the mutually-exclusive domain flags present; tests that + * exercise those checks must pass the matching flags explicitly here. + */ + cliArgs?: ReadonlyArray; } function jsonResponse( @@ -122,15 +129,20 @@ function setup(opts: SetupOpts = {}) { }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); - const layer = buildLegacyTestRuntime({ - out, - api: { layer: api.layer, httpClientLayer: api.httpClientLayer }, - cliConfig, - telemetry: telemetry.layer, - linkedProjectCache: cache.layer, - analytics, - goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), - }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api: { layer: api.layer, httpClientLayer: api.httpClientLayer }, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + analytics, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }), + Stdio.layerTest({ + args: Effect.succeed(opts.cliArgs ?? ["sso", "update", VALID_PROVIDER_ID]), + }), + ); return { layer, out, api, analytics, telemetry, cache }; } @@ -200,40 +212,237 @@ describe("legacy sso update integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --domains + --add-domains fails", () => { - const { layer } = setup(); + it.live("mutex check: --domains + --add-domains fails with cobra's exact error text", () => { + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains", "a.com", "--add-domains", "b.com"], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, domains: ["a.com"], addDomains: ["b.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoMutexFlagError"); + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + // Byte-matches cobra's `validateExclusiveFlagGroups` template + // (`flag_groups.go:204`): group in registration order, changed flags + // sorted alphabetically — "add-domains" < "domains". + expect(dump).toContain( + "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", + ); } }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --domains + --remove-domains fails", () => { - const { layer } = setup(); + it.live("mutex check: --domains + --remove-domains fails with cobra's exact error text", () => { + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--domains", + "a.com", + "--remove-domains", + "b.com", + ], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, domains: ["a.com"], removeDomains: ["b.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + expect(dump).toContain( + "if any flags in the group [domains remove-domains] are set none of the others can be; [domains remove-domains] were all set", + ); + } }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --metadata-file + --metadata-url fails", () => { - const { layer } = setup(); + it.live( + "mutex check: an explicit but empty --domains= still conflicts with --add-domains (changed, not truthy)", + () => { + // `--domains=` parses to an empty array, but cobra's `pflag.Changed` + // tracks that the flag was passed at all, not the resulting value — the + // same "changed vs truthy" gap CLI-1860 fixed for `functions download`'s + // `--use-docker`. Gating on `.length > 0` would miss this combination. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains=", "--add-domains", "b.com"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ ...defaultFlags, domains: [], addDomains: ["b.com"] }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySsoMutexFlagError"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "mutex check: --add-domains and --remove-domains together are not mutually exclusive", + () => { + // Go only registers ("domains","add-domains") and ("domains","remove-domains") + // as separate 2-element groups (`cmd/sso.go:179-180`) — add-domains and + // remove-domains together, without --domains, is not a violation. + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--add-domains", + "b.com", + "--remove-domains", + "c.com", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ ...defaultFlags, addDomains: ["b.com"], removeDomains: ["c.com"] }), + ); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("mutex check: all three domain flags set reports the --add-domains group first", () => { + // Pins the `SSO_UPDATE_MUTEX_GROUPS` array order: cobra's sorted-key + // iteration ("domains add-domains" < "domains remove-domains") means the + // add-domains group is checked — and its error returned — first when all + // three domain flags collide at once. + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--domains", + "a.com", + "--add-domains", + "b.com", + "--remove-domains", + "c.com", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ + ...defaultFlags, + domains: ["a.com"], + addDomains: ["b.com"], + removeDomains: ["c.com"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain( + "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", + ); + expect(dump).not.toContain("remove-domains"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("mutex check: a flag-group violation wins over an invalid provider ID", () => { + // Cobra runs `ValidateFlagGroups` before `RunE` (`command.go:1010,1014`); + // Go's provider-ID format check lives inside `RunE` (`cmd/sso.go:90-91`). + // So an invalid UUID combined with a mutex violation must surface the + // mutex error, not `LegacySsoInvalidUuidError`. + const { layer } = setup({ + cliArgs: ["sso", "update", "not-a-uuid", "--domains", "a.com", "--add-domains", "b.com"], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, - metadataFile: Option.some("/tmp/x.xml"), - metadataUrl: Option.some("https://idp.example.com/m"), + providerId: "not-a-uuid", + domains: ["a.com"], + addDomains: ["b.com"], }), ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + expect(dump).not.toContain("LegacySsoInvalidUuidError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "mutex check: --metadata-file + --metadata-url fails with cobra's exact error text", + () => { + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--metadata-file", + "/tmp/x.xml", + "--metadata-url", + "https://idp.example.com/m", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ + ...defaultFlags, + metadataFile: Option.some("/tmp/x.xml"), + metadataUrl: Option.some("https://idp.example.com/m"), + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + // Go registers this pair too (`cmd/sso.go:178`) — it was left emitting + // a hand-written message alongside the domains groups' custom text + // before this fix; now all three of `sso update`'s mutex groups on + // this command share the same byte-exact cobra template. + expect(dump).toContain( + "if any flags in the group [metadata-file metadata-url] are set none of the others can be; [metadata-file metadata-url] were all set", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "mutex check: a bare --metadata-file followed by --metadata-url is not a violation", + () => { + // pflag's `--flag arg` branch consumes the very next argv token as the + // value unconditionally (`flag.go:1013-1031`), so real cobra parses this + // as `metadata-file` receiving the literal value `"--metadata-url"` — + // `metadata-url` is never parsed as its own flag and stays unset. The + // TS CLI's own parser (unlike pflag) never hands a dash-prefixed token + // to a non-boolean flag as a bare value, so here both flags resolve to + // `Option.none()` — but the raw-argv mutex scan must reach the same + // "not a violation" conclusion pflag does, not double-count the + // `--metadata-url` token as a second explicit flag. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--metadata-file", "--metadata-url"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("mutex check: a bare --add-domains followed by --domains=... is not a violation", () => { + // Same consumed-value class as the metadata-file/metadata-url case + // above, but for the domains group: pflag would hand `add-domains` the + // literal value `"--domains=x.com"` and never parse `--domains` at all. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--add-domains", "--domains=x.com"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); + expect(Exit.isSuccess(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index d9e3ac4a7a..6dc07b8140 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -1,60 +1,259 @@ # `supabase start` +Native TypeScript port of Go's `internal/start` (+ the Postgres-container half of +`internal/db/start`). Talks directly to Docker via subprocess (`docker`/`podman`), +mirroring Go's sequential per-container `DockerStart` — it does not use Docker Compose +(the one `docker/compose` import Go has is an internal, best-effort concurrent +image-pre-pull helper this port never depends on) and it does not go through +`@supabase/stack/effect`'s orchestration model (see the CLI-1323 plan's "Critical +architectural finding" for why: that runtime is a deliberately different local-dev +product — no Kong gateway, native-binary-first, auto-allocated ports, opaque API keys — +that would break the Go-parity contract this port exists to provide). + +## Scope + +Edge Runtime bring-up, the fresh-volume DB schema/migration/seed setup pipeline, and +fresh-volume storage-bucket seeding are all now natively implemented (see below) — this +section previously listed them as out-of-scope follow-ups. + +One piece of Go's `start` remains explicitly **out of scope**, discovered during this +port's own research (not called out in Go's public docs or the original port plan) and +documented at its exact Go call site in `start.handler.ts`: + +1. **Linked-project version-check suggestion** (`internal/start/start.go:61-63`, delegates + to `internal/services.CheckVersions`) — a best-effort Management API call, made only + when a project happens to be linked _and_ the user is logged in, purely to print an + "update available" hint. Every error is silently swallowed in Go. Omitted entirely — + this port has zero Management API dependency for `start`, by design. + +### Fresh-volume DB setup (`legacyStartSetupLocalDatabase`) + +Ported: Go's `SetupLocalDatabase` → `initSchema` → +`initRealtimeJob`/`initStorageJob`/`initAuthJob` pipeline (`internal/db/start/`). Gated on +`isFreshVolume` (`legacyStartVolumeExists` on the Postgres volume, checked BEFORE the +volume is created), matching Go's `NoBackupVolume` — this same check also selects which of +`Starting database...`/`Starting database from backup...` prints to stderr immediately +before Postgres's container is created (`db/start/start.go:165-175`). Runs immediately +after Postgres's own health check passes, before "Starting containers..." prints and +before any other service starts. Opens a direct `LegacyDbConnection` session to the +host-facing Postgres address (PG<=14: execs schema/globals/API-privileges SQL over that +session; PG>=15: runs three one-shot `LegacyDockerRun` jobs instead, gated independently on +`realtime.enabled`/`storage.enabled`/`auth.enabled`). Also upserts `[db.vault]` secrets and +seeds `supabase/roles.sql`: matching Go's own print-before-read ordering +(`pkg/migration/seed.go:88`), the `Seeding globals from roles.sql...` stderr line always +prints, whether or not the file exists — a missing file is silently tolerated (no SQL runs), +any other read/exec error still fails the run. Finally runs every pending migration + seed. +A failure at any step rolls back the whole `start` run (same as any other bring-up failure). + +`legacyStartInitCurrentBranch` (writes `supabase/.branches/_current_branch` = `"main"` if +absent) is NOT part of this fresh-volume-gated pipeline — matching Go's `initCurrentBranch` +call (`db/start/start.go:189`), it runs unconditionally on every `start`, immediately after +this pipeline's gate closes (whether or not the pipeline itself ran). + +### Edge Runtime bring-up (`legacyStartEdgeRuntimeContainer`) + +Ported: Go's `serve.ServeFunctions()` call (`internal/functions/serve/`), reusing +`shared/functions/serve.ts`'s `startEdgeRuntimeContainer` core (the same one `functions +serve` uses). Gated on `edge_runtime.enabled && !--exclude edge-runtime`, in Go's real +container-start position (between ImgProxy and pg-meta). Unlike every other service, it's +a direct `docker run -d ...` (not `docker create`+`docker start`) and is health-checked via +an HTTP probe through Kong (`/functions/v1/_internal/health`), not a Docker healthcheck — +mirrors PostgREST's own probe shape. + +### Storage bucket seeding (`legacySeedBucketsRun`) + +Ported: Go's `buckets.Run(ctx, "", false, fsys)` call (`start.go:1281-1286`, +`internal/seed/buckets`). Runs only when `isFreshVolume && Storage started`, after the bulk +health check genuinely succeeds, right before the `cli_stack_started` telemetry capture. A +seeding failure rolls back the whole `start` run, same as any other post-bring-up failure. + +A second, narrower seeding path exists for the `--ignore-health-check` downgrade branch +(`start.go:1272-1277`): when the bulk health check fails but `isFreshVolume && Storage +started`, Go re-checks Storage's health in isolation and, only if Storage itself is +healthy, seeds buckets anyway before falling through to the downgrade-to-warning behavior. +Unlike the main path above, a failure on THIS path is not swallowed by +`--ignore-health-check` — it replaces the original health error, rolls back, and fails the +command (Go's `return seedErr` instead of the downgraded `return err`). + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| ----------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always | +| `/supabase/.env`, `.env.local` | dotenv | always (`.env.local` skipped when `SUPABASE_ENV=test`) | +| project-root / `SUPABASE_ENV`-selected dotenv file | dotenv | always, same precedence chain as `stop`/`status` | +| `auth.signing_keys_path` file | JSON | when configured | +| `api.tls.cert_path` / `api.tls.key_path` | PEM | when `api.tls.enabled` | +| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured | +| GCP JWT credentials file | JSON | when `analytics.backend = "bigquery"` | +| `/supabase/roles.sql` | SQL | on a fresh volume (custom-roles seed) — the "Seeding globals..." message always prints first; the file itself is only read if it exists, tolerating a missing file | +| `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume, via the standard migration-apply + seed pipeline | +| `/supabase/.branches/_current_branch` | text | on every start, existence check before writing (see "Files Written") | +| `/supabase/functions/**` | — | when Edge Runtime starts, and independently when Studio starts (function discovery/config resolution + Docker bind mounts, regardless of whether Edge Runtime itself is enabled) | +| `/supabase/.temp/storage-migration` | text | always — linked-project Storage migration pin (`DB_MIGRATIONS_FREEZE_AT`), written by `supabase link`; absent/unreadable resolves to no pin | +| `/supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare,pooler}-version` | text | always — linked-project per-service image version pins, written by `supabase link`; absent/unreadable resolves to the embedded default image | +| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| --------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` | +| `/supabase/.temp/start-secrets//secret-` | varies | for Kong (`kong.yml`, TLS cert, TLS key), Postgres (`pgsodium_root.key`), and Supavisor (`pooler_tenant.exs`) — see below | +| `/supabase/.temp/start-secrets//{env,multiline-env,main}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts and bootstrap template — see below | + +Kong's `custom_nginx.template`, Vector's `vector.yaml`, and Postgres's own bootstrap +script (`postgresql.conf`-equivalent setup) are all rendered in memory and injected +directly into each container's entrypoint (a `sh -c '... heredoc ...'` command) — +never written to the host filesystem, since none of them carries secret content. +Kong's `kong.yml`/TLS cert/TLS key, Postgres's `pgsodium_root.key`, and Supavisor's +`pooler_tenant.exs` DO carry secret content (a service-role-key-derived bearer/query +key, TLS private key material, and the DB password respectively) and are instead +written to `/supabase/.temp/start-secrets//` (directory mode +`0700`, files mode `0644` — world-readable, since Kong (uid 100) and Postgres's +post-privilege-drop `postgres` user read these bind-mounted files as non-root, and a +Linux/Podman bind mount preserves the host file's mode verbatim) and +bind-mounted `:ro` into the container at the exact path each container's +entrypoint/`Cmd` expects — see `container-lifecycle.ts`'s `legacyStageStartSecretFiles` +doc comment for the full rationale (CWE-214/522: keeping secret content out of the +`docker create` argv the host can see via `ps`/`/proc//cmdline`) and for why this +directory is a DETERMINISTIC, PERSISTENT path under the project's own workdir rather +than an ephemeral OS temp dir — every one of these three containers runs with +`restartPolicy: "unless-stopped"`, so the files must survive a host/Docker-daemon +restart for dockerd to successfully re-attach the bind mount. The directory is +recreated fresh (any stale contents removed first) on every `start` invocation that +reaches container creation, and is cleaned up immediately if `docker create`/`docker +start` itself fails — otherwise it is left in place for the life of the container. +Studio reads/writes SQL snippets under `/supabase/snippets/` at its own +runtime — that's Studio's behavior, not something `start` itself writes. + +Edge Runtime's own JWT/service-role-key/configured-secret env file, multiline-env +script + value files, and bootstrap `index.ts` template (`shared/functions/serve.ts`'s +`writeDockerEnvFile`/`writeDockerMultilineEnvScript`/`writeServeMainTemplateFile`) are +staged the same way, under `/supabase/.temp/start-secrets//{env,multiline-env,main}/` (directory mode `0700`, files mode `0600`), +bind-mounted `:ro` into the container — a deterministic, persistent path rather than +`os.tmpdir()` (which is frequently tmpfs and gets wiped on reboot) so +`legacyCleanupStartSecrets` (see the Exit Codes/rollback section below) can reclaim +them on `stop` or a failed-start rollback, exactly like the Kong/Postgres/Supavisor +directories above. Each of the three writers removes and recreates its own +subdirectory fresh on every call (self-healing, same as the directory above), so a +shrinking env set never leaves stale files behind. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------ | --------------------- | ------------ | ------------------------- | +| GET | `/storage/v1/bucket` | Kong service-role key | — | existing bucket names/ids | +| POST/PUT | `/storage/v1/bucket[/]` | Kong service-role key | bucket props | created/updated bucket | + +Local-only: the Storage bucket-seeding step (fresh volume + Storage enabled) talks to the +LOCAL Storage service through Kong, never the Management API. See "Scope" above for the +one Go behavior (`CheckVersions`) that _would_ call the Management API and is deliberately +not implemented. ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | +| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | +| `SUPABASE_WORKDIR` | Resolves `LegacyCliConfig.workdir` | no | +| `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | +| `DOCKER_HOST` | Read to discover the Docker daemon's own address, then re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | +| `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | + +`docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — all containers started | -| `1` | malformed config | -| `1` | Docker daemon not running or connection error | -| `1` | one or more containers failed health check | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — every started container passed its health check | +| `0` | the stack was already running — shows status instead of restarting | +| `0` | `--ignore-health-check` set and one or more containers timed out — the failure is printed and swallowed, no rollback | +| `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | +| `1` | malformed `config.toml` / `Config.Validate` failure | +| `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | +| `1` | image pull exhausted across every registry candidate | +| `1` | network, volume, container create, or container start failure (including a port conflict) — rolls back everything created so far | +| `1` | health check timeout **without** `--ignore-health-check` — rolls back | +| `1` | Postgres itself fails to start or its own health wait times out, **without** `--ignore-health-check` — rolls back | +| `0` | `--ignore-health-check` set and Postgres's own health wait times out — the failure is printed and swallowed, no rollback; no OTHER service is ever created (Postgres's failure is returned before any other bring-up step runs), but the command still prints "Started..." + the (config-derived) status table | +| `1` | fresh-volume DB setup failure (schema SQL / one-shot migrate job / vault upsert / roles seed / migration-apply) — rolls back | +| `1` | fresh-volume bucket-seeding failure — rolls back | -## Output +Rollback (`legacyRollbackStart`) tears down everything created so far by Docker label, +matching Go's `DockerRemoveAll`, and never masks the original failure — a rollback error +is logged to stderr and swallowed. `deleteVolumes` mirrors Go's `utils.NoBackupVolume` +exactly: `true` only when this run's Postgres volume was freshly created (so a failed +first-ever `start` prunes its own empty volume too), `false` otherwise (never touches a +pre-existing user's data on a failed restart). Rollback also reclaims this run's own +`/supabase/.temp/start-secrets/` directories (via +`legacyCleanupStartSecrets`, `legacy/shared/legacy-start-secrets-cleanup.ts`) once +teardown is CONFIRMED complete — the matching containers come from +`legacyDockerRemoveAll`'s `onContainersRemoved` hook, which fires only once `docker +container prune` has actually removed them (not at the initial listing), so cleanup only +ever targets containers this failed run itself created AND actually tore down. Each +container's directory is located via its own `com.supabase.cli.workdir` label (stamped on +every container `start` creates); this run's own workdir is only the fallback for a +container missing that label. A later successful `stop` reclaims the same directories for +a normal (non-rollback) teardown — see `stop`'s own `SIDE_EFFECTS.md`. -### `--output-format text` (Go CLI compatible) +## Telemetry Events Fired -Streams Docker pull and container start progress to stdout. Prints service URLs on success. +| Event | When | Notable properties / groups | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via `withLegacyCommandInstrumentation`) | `exit_code`, `duration_ms`, `flags` | +| `cli_stack_started` | once, only on a genuine bulk health-check success — never fires on an `--ignore-health-check` downgrade-to-warning path, Postgres's own or the later bulk check's, matching Go's capture sitting after the error-return block (`start.go:1287`) | no properties | -### `--output-format json` / `--output json` +## Output -Starts the local stack while capturing Go's start progress output, then emits the same JSON -object as `supabase status --output json`. +`start` has no Go `-o`/`--output` flag of its own — after orchestration (or the +already-running short-circuit, or the ignored-health-check path), it renders through the +exact same `status` value/pretty-table machinery `supabase status` uses, in all three TS +output modes. + +### `--output-format text` (Go CLI compatible) -Legacy `--output env|toml|yaml` follows the same path and emits the corresponding -`supabase status --output ` payload after a successful start. +- stderr (conditional, `--exclude` had invalid values): a `WARNING:` line naming the + invalid values and listing the 13 valid ones — the command still proceeds. +- stderr: already-running banner (`supabase start` in aqua) **or** the bring-up sequence: + `Starting database...` (fresh volume) / `Starting database from backup...` (existing + volume) → Postgres create+start+health-wait → `Starting containers...` → (image + pre-pull) → per-container create+start → `Waiting for health checks...` → `Started +supabase local development setup.` +- stdout: the `status` pretty table (rounded box, same renderer `supabase status` uses). +- stderr: the local-dev security notice block (bind-to-`0.0.0.0` / shared-default-keys / + no-auth-on-Studio-pgMeta-analytics warning). -### `--output-format stream-json` +### `--output-format json` / `--output-format stream-json` -Starts the local stack while capturing Go's start progress output, then emits the same JSON -object as `supabase status --output json`. +A single JSON object (or a `result` NDJSON event) carrying the same value shape +`supabase status --output json` produces. All the progress/warning/banner/security-notice +text above is suppressed in these modes — stdout stays payload-only. ## Notes -- `--exclude` / `-x` flag accepts a comma-separated list of container names to skip. -- `--ignore-health-check` suppresses unhealthy container errors and exits 0. -- `--preview` is a hidden flag to connect to a feature preview branch. -- If all containers are already running the command shows status instead. +- `--exclude`/`-x` accepts container names from the verified 13-key list (`gotrue`, + `realtime`, `storage-api`, `imgproxy`, `kong`, `mailpit`, `postgrest`, `postgres-meta`, + `studio`, `edge-runtime`, `logflare`, `vector`, `supavisor`) — `db`/`postgres` is never + excludable, matching Go. An invalid value warns and proceeds; it never fails the command. +- `--ignore-health-check` applies uniformly to EVERY service's health wait, Postgres + included — matching Go's `Run()`, which downgrades whatever `run()` returns as a whole, + not just the later bulk health check. It never skips rollback for a _creation_ failure, + only for a health _timeout_. When Postgres's OWN wait times out and the flag is set, no + other service is ever created (Postgres's failure returns before any later bring-up step + runs), yet the command still falls through to the "Started..." tail and the + (config-derived) status table, exactly like Go. When it's instead the LATER bulk health + check that times out with the flag set, and this run also freshly created the Postgres + volume and Storage started, a narrower Storage-only recheck runs and, if Storage is + healthy, buckets are seeded anyway — a failure in THAT seed step still rolls back and + fails the command despite the flag (see "Storage bucket seeding" and the `Exit Codes` + table). +- `--preview` is a hidden, parsed-but-inert flag, matching Go exactly (never read by + Go's own `start.Run`). +- The already-running check is a plain container-existence check (`docker container +inspect` on the Postgres container), not a health check — matching Go's + `AssertSupabaseDbIsRunning` naming despite what it actually verifies. diff --git a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts new file mode 100644 index 0000000000..7f681f4857 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts @@ -0,0 +1,721 @@ +/** + * Port of Go's `DockerStart` (`apps/cli-go/internal/utils/docker.go:363-440`): + * given a fully-resolved {@link LegacyStartContainerSpec} (image already + * resolved by `image-prepull.ts` — see its doc comment), sets the two + * project-identity labels, provisions this container's own named volumes, + * stages any `secretFiles` onto the host (see {@link legacyStageStartSecretFiles}), + * builds the `docker create` argv (`docker-create-args.ts`), and spawns + * `docker create` + `docker start`. + * + * Network creation (`DockerNetworkCreateIfNotExists`) is deliberately NOT part + * of this per-container function — see {@link legacyEnsureStartNetwork}'s doc + * comment for why it is hoisted to run once instead of once per container. + */ + +import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Data, Effect, Stream } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { + legacyDescribeContainerCliFailure, + spawnContainerCli, +} from "../../../shared/legacy-container-cli.ts"; +import { + legacyBindMountSpecSource, + legacyIsBindMountSource, +} from "../../../shared/legacy-docker-bind-classify.ts"; +import { + LEGACY_CLI_PROJECT_LABEL, + LEGACY_CLI_WORKDIR_LABEL, +} from "../../../shared/legacy-docker-ids.ts"; +import { isUserDefinedDockerNetwork } from "../../../../shared/functions/deploy.ts"; +import { + legacyBuildStartContainerCreateArgs, + legacyApplyBitbucketStartContainerFilter, + legacyIsDockerClientEnvKey, + type LegacyStartContainerSpec, +} from "./docker-create-args.ts"; + +/** Structural element type of {@link LegacyStartContainerSpec.secretFiles} — not exported from `docker-create-args.ts`, so referenced positionally here. */ +type LegacyStartSecretFileSpec = NonNullable[number]; + +type Spawner = ChildProcessSpawner["Service"]; + +/** + * Go's `composeProjectLabel` (`apps/cli-go/internal/utils/docker.go:60`, + * unexported there). This port does not integrate with docker-compose + * anywhere (an intentional architecture decision), but the label is still set + * unconditionally, matching Go's own unconditional assignment + * (`docker.go:376`) regardless of whether compose is actually in use: external + * tooling that groups/filters containers by this label (Docker Desktop's + * Compose view, `docker compose ls`, the VS Code Docker extension) would + * otherwise silently stop recognizing the local stack's containers. + * + * A same-value private constant already exists at + * `shared/functions/deploy.ts` (`dockerComposeProjectLabel`, for the unrelated + * `functions deploy` Docker Desktop extension gateway) but is neither exported + * nor in the same Docker-usage domain as `start` — not hoisted from there. + */ +export const LEGACY_COMPOSE_PROJECT_LABEL = "com.docker.compose.project"; + +/** `docker network create --label ...`/`docker volume create --label ...` failed. */ +export class LegacyStartNetworkCreateError extends Data.TaggedError( + "LegacyStartNetworkCreateError", +)<{ + readonly message: string; +}> {} + +export class LegacyStartVolumeCreateError extends Data.TaggedError("LegacyStartVolumeCreateError")<{ + readonly message: string; +}> {} + +/** `docker create` failed. */ +export class LegacyStartContainerCreateError extends Data.TaggedError( + "LegacyStartContainerCreateError", +)<{ + readonly message: string; +}> {} + +/** `docker start` failed — see {@link legacyPortConflictSuggestion} for the port-already-allocated case. */ +export class LegacyStartContainerStartError extends Data.TaggedError( + "LegacyStartContainerStartError", +)<{ + readonly message: string; +}> {} + +/** Every failure {@link legacyStartContainer} itself can produce (network creation is separate, see {@link legacyEnsureStartNetwork}). */ +export type LegacyStartContainerError = + | LegacyStartVolumeCreateError + | LegacyStartContainerCreateError + | LegacyStartContainerStartError; + +export interface LegacyStartContainerOpts { + /** + * Go's `Config.ProjectId`, already sanitized (`legacySanitizeProjectId`) by + * the caller's config-load pipeline — `DockerStart` itself performs no + * sanitization, it just reads the already-sanitized singleton + * (`docker.go:375-376`). Merged onto both {@link LEGACY_CLI_PROJECT_LABEL} + * and {@link LEGACY_COMPOSE_PROJECT_LABEL}, overwriting any value the caller + * may have already set under those keys in `spec.labels` — exactly like + * Go's unconditional map assignment. + */ + readonly projectId: string; + /** + * `os.Getenv("BITBUCKET_CLONE_DIR") != ""` — see `legacyIsBitbucketPipeline` + * (`shared/legacy-bitbucket-pipeline.ts`). Passed in rather than read here so + * this module stays a pure effect orchestrator with no direct env access, + * matching `legacyApplyBitbucketStartContainerFilter`'s own boolean-flag + * shape (`docker-create-args.ts`). + */ + readonly isBitbucketPipeline: boolean; + /** + * `LegacyCliConfig.workdir` — the project's own working directory. Roots + * {@link legacyStageStartSecretFiles}'s deterministic, persistent host directory + * (`/supabase/.temp/start-secrets//`) so staged secret files + * survive a host/Docker-daemon restart — see that function's doc comment for why. + * + * Also stamped onto every created container as {@link LEGACY_CLI_WORKDIR_LABEL} (see + * that constant's doc comment) so a later `stop`/{@link legacyRollbackStart} can find + * this exact directory again from the container's own label, without depending on + * being invoked from the same cwd/`--workdir` `start` was. + */ + readonly workdir: string; + /** + * `DockerStart`'s platform-specific `extraHosts` package var + * (`docker_linux.go`/`docker_darwin.go`/`docker_windows.go`), merged onto + * EVERY container's `HostConfig.ExtraHosts` (`docker.go:378`) — Linux-only + * (`["host.docker.internal:host-gateway"]`); empty on Docker Desktop + * platforms, which already resolve that hostname natively. Merged here + * (not per-spec) for the same reason the two project-identity labels are: + * Go applies it identically to every container this orchestrator creates. + */ + readonly extraHosts: ReadonlyArray; +} + +function collectText(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +/** + * Extracts every named-volume source from `binds` (Go's `loader.ParseVolume` + * classification loop, `docker.go:388-399`): a bind is `source:target[:mode]` + * (see `docker-create-args.ts`'s `LegacyStartContainerSpec.binds` doc comment + * and its own worked examples in `docker-create-args.unit.test.ts`), and its + * source segment is a named volume exactly when + * {@link legacyIsBindMountSource} says it is NOT a bind-mount path. No dedupe + * is applied — Go's own `sources` slice doesn't dedupe either, and + * `Docker.VolumeCreate` is idempotent for a repeated name. + */ +function legacyNamedVolumeSources(binds: ReadonlyArray): ReadonlyArray { + const sources: Array = []; + for (const bind of binds) { + const source = legacyBindMountSpecSource(bind); + if (source.length > 0 && !legacyIsBindMountSource(source)) { + sources.push(source); + } + } + return sources; +} + +/** + * Whether `docker`/`podman network create`'s stderr reports the network + * already existing — the CLI-subprocess equivalent of Go's + * `errdefs.IsConflict(err)` (`docker.go:70`), which inspects a structured + * Engine API error instead of stderr text. Docker's real message is `Error + * response from daemon: network with name already exists`; Podman's is + * worded differently (`network name already used`), hence the broader + * pattern rather than matching Docker's exact sentence. + */ +function legacyIsNetworkAlreadyExistsError(stderr: string): boolean { + return /already exists|already used/iu.test(stderr); +} + +/** Go's `portErrorPattern` (`apps/cli-go/internal/utils/docker.go:657`). */ +const LEGACY_PORT_BIND_ERROR_PATTERN = /Bind for (.*) failed: port is already allocated/; + +function legacyParsePortBindError(stderr: string): string | undefined { + return LEGACY_PORT_BIND_ERROR_PATTERN.exec(stderr)?.[1]; +} + +/** + * A scoped-down port of Go's `suggestDockerStop` + * (`apps/cli-go/internal/utils/docker.go:667-686`): Go lists every running + * container, matches the failed host port against each container's own + * published ports, and reports either `supabase stop --project-id ` (the + * port owner carries the CLI project label) or a bare `docker stop ` + * (it doesn't) — then `docker.go:425-436` appends a further "configure a + * different port" suggestion on top of that. + * + * Reproducing the lookup would mean this pure CLI-orchestration function also + * lists and inspects every other running container purely to word a hint — + * disproportionate plumbing for a suggestion string. This reproduces only the + * detection Go itself starts from (the same regex match) and folds both of + * Go's suggestion branches into one still-actionable sentence that covers + * either case without the extra lookup. + */ +function legacyPortConflictSuggestion(hostPort: string, serviceLabel: string): string { + return ( + `\nTry stopping the project or container already using ${hostPort} ` + + "(`docker ps` lists what's bound to it, or `supabase stop` for another local Supabase project), " + + `or configure a different ${serviceLabel} port in supabase/config.toml.` + ); +} + +/** + * Go's `DockerNetworkCreateIfNotExists` (`docker.go:63-77`) via `docker + * network create --label ... `, treating "already exists" as + * success. + * + * Called ONCE, up front, by the caller orchestrating a whole `start` run — + * NOT per-container the way Go's `DockerStart` calls it on every single + * invocation. Go's repeated call is a no-op after the first (the network + * already exists), so this is a pure optimization, not a behavior change: a + * `start` run's containers are exclusively created by this same code path in + * one process, never interleaved with an external network deletion, so the + * network is guaranteed to still exist for every later `legacyStartContainer` + * call in the same run. + * + * Mirrors Go's own `isUserDefined(mode)` guard (`docker.go:65`, + * `docker_linux.go:10` and platform siblings) that runs first inside + * `DockerNetworkCreateIfNotExists` itself: `--network-id default|bridge|host| + * none` names a built-in Docker network that already exists and cannot be + * created (`docker network create host` errors with "operation is not + * permitted on predefined host network"), so this returns immediately without + * spawning `docker network create` at all for those names, reusing the same + * `isUserDefinedDockerNetwork` check `shared/functions/deploy.ts` already + * applies for the unrelated `functions deploy` extension-gateway network. + */ +export function legacyEnsureStartNetwork( + spawner: Spawner, + networkId: string, + labels: Readonly>, +): Effect.Effect { + if (!isUserDefinedDockerNetwork(networkId)) { + return Effect.void; + } + return Effect.scoped( + Effect.gen(function* () { + const args = [ + "network", + "create", + ...Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]), + networkId, + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartNetworkCreateError({ + message: `failed to create docker network: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyStartNetworkCreateError({ message: "failed to create docker network" }), + ), + ); + if (exitCode !== 0 && !legacyIsNetworkAlreadyExistsError(stderr)) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyStartNetworkCreateError({ + message: + message.length > 0 + ? `failed to create docker network: ${message}` + : "failed to create docker network", + }), + ); + } + }), + ); +} + +/** + * Go's per-source-name `Docker.VolumeCreate` call (`docker.go:407-415`) via + * `docker volume create --label ...`. Unlike network creation, Go applies no + * "already exists" tolerance here — `VolumeCreate` is already idempotent for a + * repeated name with matching options, so any non-zero exit is a real failure. + */ +export function legacyEnsureStartVolume( + spawner: Spawner, + name: string, + labels: Readonly>, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const args = [ + "volume", + "create", + ...Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]), + name, + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartVolumeCreateError({ + message: `failed to create volume: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyStartVolumeCreateError({ message: "failed to create volume" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyStartVolumeCreateError({ + message: + message.length > 0 + ? `failed to create volume: ${message}` + : "failed to create volume", + }), + ); + } + }), + ); +} + +/** `docker volume inspect` failed to spawn at all (no docker/podman binary). */ +export class LegacyStartVolumeInspectError extends Data.TaggedError( + "LegacyStartVolumeInspectError", +)<{ + readonly message: string; +}> {} + +/** Docker's/Podman's "no such volume" stderr shape for `volume inspect`. */ +function isVolumeNotFoundMessage(message: string): boolean { + return /no such volume/iu.test(message); +} + +/** + * Go's pre-create existence check (`_, err := utils.Docker.VolumeInspect(ctx, + * utils.DbId); utils.NoBackupVolume = errdefs.IsNotFound(err)`, + * `apps/cli-go/internal/db/start/start.go:165-167`), run BEFORE the volume is + * created — `docker volume create` is idempotent, so creating first would lose + * whether the volume already existed. `docker volume inspect ` exits 0 + * when the volume exists; a confirmed "no such volume" resolves to `false`, + * matching Go's `errdefs.IsNotFound`. Any OTHER inspect failure (permission + * denied, daemon unreachable, …) resolves to `true` instead — Go's + * `errdefs.IsNotFound(err)` is `false` for any error that isn't specifically a + * not-found, so Go always defaults to treating the volume as pre-existing + * (protected from rollback's `volume prune`) unless it can positively confirm + * otherwise; collapsing every non-zero exit into "doesn't exist" would let an + * ambiguous inspect failure on a stack with real prior data get pruned by + * {@link legacyRollbackStart} after any later failure — a data-loss + * regression Go's own gate doesn't have. Only a spawn failure (neither + * `docker` nor `podman` on `PATH`) is a real error here. + * + * A separate, additional export — NOT called from {@link legacyEnsureStartVolume} + * itself, whose existing idempotent-create behavior must not change. The caller + * orchestrating a `start` run checks this BEFORE creating the volume, to gate the + * `SetupLocalDatabase`-equivalent pipeline and bucket seeding on "was this a + * fresh volume", matching Go's exact check-before-create ordering + * (`internal/db/start/start.go:165-184`). + */ +export function legacyStartVolumeExists( + spawner: Spawner, + name: string, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["volume", "inspect", name], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartVolumeInspectError({ + message: `failed to inspect volume: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyStartVolumeInspectError({ message: "failed to inspect volume" }), + ), + ); + if (exitCode === 0) return true; + return !isVolumeNotFoundMessage(stderr); + }), + ); +} + +function legacyDockerCreateContainer( + spawner: Spawner, + args: ReadonlyArray, + env: Readonly>, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + // `docker-create-args.ts` emits the key-only `-e KEY` form (never `-e KEY=value`) so + // secrets never appear in argv/`ps`/`/proc//cmdline` (CWE-214/209) — Docker then + // resolves each key's value from THIS spawned process's own environment. `extendEnv: + // true` keeps the rest of the parent's env (PATH, the real DOCKER_HOST, …) so the docker + // CLI invocation itself still behaves correctly; `env` supplies the actual secret values. + // Matches the same pattern already used for `docker run` (`legacy-docker-run.layer.ts`) + // and image resolution (`legacy-docker-image-resolve.ts`). + // + // Callers must have already stripped `legacyIsDockerClientEnvKey` keys (e.g. a + // container-facing `DOCKER_HOST`, set by Vector's spec for a tcp/npipe daemon host) from + // `env` before calling this function — those are emitted inline as `-e KEY=value` by + // `legacyBuildStartContainerCreateArgs` instead, since `extendEnv: true` merges `env` INTO + // this spawned process's own environment (per Effect's `ChildProcess` semantics, + // prioritizing `env`'s values), and that same environment is what the `docker`/`podman` + // CLI client itself reads `DOCKER_HOST` from to pick which daemon to talk to. Letting a + // container-facing `DOCKER_HOST` leak in here would hijack this `docker create` call's own + // daemon target before the container even exists. + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env, + extendEnv: true, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartContainerCreateError({ + message: `failed to create docker container: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectText(child.stdout), + collectText(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => + new LegacyStartContainerCreateError({ message: "failed to create docker container" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyStartContainerCreateError({ + message: + message.length > 0 + ? `failed to create docker container: ${message}` + : "failed to create docker container", + }), + ); + } + return stdout.trim(); + }), + ); +} + +function legacyDockerStartContainer( + spawner: Spawner, + containerId: string, + spec: LegacyStartContainerSpec, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["start", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartContainerStartError({ + message: `failed to start docker container "${spec.containerName}": ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => + new LegacyStartContainerStartError({ + message: `failed to start docker container "${spec.containerName}"`, + }), + ), + ); + if (exitCode !== 0) { + const trimmed = stderr.trim(); + const base = `failed to start docker container "${spec.containerName}": ${ + trimmed.length > 0 ? trimmed : `exit ${exitCode}` + }`; + const hostPort = legacyParsePortBindError(trimmed); + if (hostPort === undefined) { + return yield* Effect.fail(new LegacyStartContainerStartError({ message: base })); + } + const serviceLabel = spec.networkAliases?.[0] ?? spec.containerName; + return yield* Effect.fail( + new LegacyStartContainerStartError({ + message: `${base}${legacyPortConflictSuggestion(hostPort, serviceLabel)}`, + }), + ); + } + }), + ); +} + +/** + * Writes each {@link LegacyStartContainerSpec.secretFiles} entry's `content` + * to a HOST directory scoped to this container, at the DETERMINISTIC path + * `/supabase/.temp/start-secrets//` (matching this + * codebase's existing `/supabase/.temp/` convention for CLI-owned + * scratch state — see `legacy-linked-project-cache.layer.ts`), directory mode + * `0700` and one file per entry force-`chmod`'d to mode `0644` after writing + * (a creation-time `writeFile({ mode })` is only ever the argument to the + * underlying `open()`/`creat()` syscall, which the kernel ANDs with `~umask` + * — under a restrictive shell umask like `077`/`027` the file would otherwise + * land at `0600`, unlike `chmod`, which sets the mode unconditionally) — the + * directory stays + * owner-only (the kernel checks execute/search permission on this ancestor + * directory before it ever checks a file's own mode, so no other local user + * on the host can list the staged file names/count or read their contents + * while they exist), but each file is world-readable: it is bind-mounted + * `:ro` into a container that reads it as a NON-ROOT in-container user + * (Kong's image runs as uid 100 `kong`; Postgres's entrypoint drops root and + * reads `pgsodium_root.key` as the `postgres` user), and a Linux/Podman bind + * mount preserves the host file's uid/mode verbatim inside the container, so + * an arbitrary host-invoking uid at `0600` would get `EACCES` there — Go's + * own equivalent (heredoc'd directly into the container by a root-authored + * entrypoint script) already lands at world-readable `0644`, matching this + * exactly — then returns the `::ro` + * bind for each. Mirrors this same session's `-e KEY`-only env fix + * (`legacyDockerCreateContainer`'s doc comment) for entrypoint/`Cmd`-bound + * secret content instead of env values: the file's HOST path is the only + * thing that ever reaches `docker create`'s argv, never the secret `content` + * itself (CWE-214/522). + * + * Deliberately NOT an ephemeral `os.tmpdir()` directory (`fs.mkdtemp`, this + * function's original implementation): every container that uses this + * mechanism (Kong, Postgres, Supavisor) runs with `restartPolicy: + * "unless-stopped"`, so dockerd re-attaches its bind mounts on its own after + * a host/daemon restart — by which point `supabase start`'s own process (and + * any `os.tmpdir()` directory it made, which is frequently tmpfs on Linux and + * wiped on reboot regardless) is long gone. Re-attaching a bind mount whose + * host source no longer exists either fails outright or (with the legacy `-v + * host:container` syntax) silently recreates an empty directory there — + * either way silently dropping the secret content (e.g. Postgres's + * `pgsodium_root.key`) until the user manually reruns `stop` + `start`. A + * path rooted in the project's own working directory survives the restart, + * exactly like Go's own heredoc'd-into-`Entrypoint`/`Cmd` approach does (via + * dockerd's own persisted container metadata) — without reintroducing the + * argv-exposure problem that approach has. + * + * Self-healing: any pre-existing directory at this path is removed FIRST, + * before writing fresh files, on every call — so a config change that + * shrinks or removes `secretFiles` between `start` invocations never leaves a + * stale file behind, and no orphaned directories accumulate across restarts. + * `legacyStartContainer` never resolves this for a container while an + * earlier instance of that same container might still be reading from it — + * see that function's doc comment. + */ +function legacyStageStartSecretFiles( + secretFiles: ReadonlyArray, + containerName: string, + workdir: string, +): Effect.Effect< + { readonly binds: ReadonlyArray; readonly cleanup: () => Promise }, + LegacyStartContainerCreateError +> { + const dir = join(workdir, "supabase", ".temp", "start-secrets", containerName); + return Effect.tryPromise({ + try: async () => { + await rm(dir, { recursive: true, force: true }); + if (secretFiles.length === 0) { + return { binds: [], cleanup: () => Promise.resolve() }; + } + try { + await mkdir(dir, { recursive: true, mode: 0o700 }); + const binds = await Promise.all( + secretFiles.map(async (secretFile, index) => { + const hostPath = join(dir, `secret-${index}`); + await writeFile(hostPath, secretFile.content, { mode: 0o644 }); + // `writeFile`'s `mode` is only a creation-time hint the kernel ANDs with the + // process umask — force the final mode explicitly so a restrictive umask (e.g. + // `077`/`027`) can't silently narrow this to `0600` and break the non-root + // in-container reader (see this function's doc comment). + await chmod(hostPath, 0o644); + return `${hostPath}:${secretFile.containerPath}:ro`; + }), + ); + return { binds, cleanup: () => rm(dir, { recursive: true, force: true }) }; + } catch (cause) { + await rm(dir, { recursive: true, force: true }); + throw cause; + } + }, + catch: (cause) => + new LegacyStartContainerCreateError({ + message: `failed to create docker container: failed to stage container secret files: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + }), + }); +} + +/** + * Port of Go's `DockerStart` (`apps/cli-go/internal/utils/docker.go:363-440`), + * minus image resolution (already done by `image-prepull.ts`) and network + * creation (hoisted, see {@link legacyEnsureStartNetwork}): + * + * 1. Merge the two project-identity labels onto `spec.labels`. + * 2. Provision this container's own named volumes (skipped entirely under + * Bitbucket Pipelines, matching Go). + * 3. Apply the Bitbucket named-volume-bind / security-opt filter + * (`legacyApplyBitbucketStartContainerFilter`, already ported). + * 4. Stage any `secretFiles` onto the host and append their bind mounts + * (`legacyStageStartSecretFiles`) — a TS-port-only step with no Go + * equivalent, see `docker-create-args.ts`'s `secretFiles` doc comment. + * 5. `docker create` then `docker start`. The staged secret files are left in + * place on success — they must persist for the container's whole + * lifetime so a `restartPolicy: "unless-stopped"` restart (dockerd + * re-attaching bind mounts after a host reboot, long after this process + * has exited) can still read them; see `legacyStageStartSecretFiles`'s doc + * comment. Only cleaned up (best-effort, `Effect.onError` — not + * `Effect.tapError`, which is built on `Cause.findError` and never sees a + * pure SIGINT/SIGTERM interrupt, the same gap already fixed for the + * top-level bring-up rollback in `start.handler.ts`) when `docker + * create`/`docker start` itself FAILS, is interrupted, or the container + * never successfully starts — nothing is depending on the files at that + * point, and this fires regardless of whether a container was ever + * created, so it doesn't depend on `docker ps`-based discovery the way + * `legacyCleanupStartSecrets` does. Once the container is actually torn + * down (a failed-start rollback, or a later `stop`), + * `legacyCleanupStartSecrets` (`legacy-start-secrets-cleanup.ts`) reclaims + * the staged directory then instead. + * + * Resolves to the created container's id/name on success. + */ +export function legacyStartContainer( + spawner: Spawner, + spec: LegacyStartContainerSpec, + opts: LegacyStartContainerOpts, +): Effect.Effect { + return Effect.gen(function* () { + const labels: Record = { + ...spec.labels, + [LEGACY_CLI_PROJECT_LABEL]: opts.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: opts.projectId, + }; + // The workdir label is stamped on the CONTAINER only, not on its named volumes below + // (`legacyEnsureStartVolume` is passed `labels`, not `containerLabels`) — a volume's own + // name already carries the project id, and nothing ever reads a workdir label back off a + // volume the way `legacyListContainerIdsAndNames` does for containers. + const containerLabels: Record = { + ...labels, + [LEGACY_CLI_WORKDIR_LABEL]: opts.workdir, + }; + const labeledSpec: LegacyStartContainerSpec = { + ...spec, + labels: containerLabels, + extraHosts: [...(spec.extraHosts ?? []), ...opts.extraHosts], + }; + + if (!opts.isBitbucketPipeline) { + for (const name of legacyNamedVolumeSources(labeledSpec.binds)) { + yield* legacyEnsureStartVolume(spawner, name, labels); + } + } + + const finalSpec = legacyApplyBitbucketStartContainerFilter( + labeledSpec, + opts.isBitbucketPipeline, + ); + + const { binds: secretBinds, cleanup: cleanupSecretFiles } = yield* legacyStageStartSecretFiles( + finalSpec.secretFiles ?? [], + finalSpec.containerName, + opts.workdir, + ); + const specWithSecretBinds: LegacyStartContainerSpec = + secretBinds.length === 0 + ? finalSpec + : { ...finalSpec, binds: [...finalSpec.binds, ...secretBinds] }; + + return yield* Effect.gen(function* () { + const createArgs = legacyBuildStartContainerCreateArgs(specWithSecretBinds); + // `legacyIsDockerClientEnvKey` keys (e.g. Vector's container-facing `DOCKER_HOST`) are + // already emitted inline as `-e KEY=value` by `legacyBuildStartContainerCreateArgs` above — + // see `legacyDockerCreateContainer`'s doc comment for why they must not also reach the + // spawned `docker create` process's own environment. + const createProcessEnv = Object.fromEntries( + Object.entries(specWithSecretBinds.env).filter(([key]) => !legacyIsDockerClientEnvKey(key)), + ); + const containerId = yield* legacyDockerCreateContainer(spawner, createArgs, createProcessEnv); + yield* legacyDockerStartContainer(spawner, containerId, specWithSecretBinds); + return containerId; + }).pipe( + Effect.onError(() => + Effect.promise(cleanupSecretFiles).pipe(Effect.catchCause(() => Effect.void)), + ), + ); + }); +} diff --git a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts new file mode 100644 index 0000000000..b1ef49c9d4 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts @@ -0,0 +1,837 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Fiber, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, beforeEach } from "vitest"; + +import { + LegacyStartContainerCreateError, + LegacyStartContainerStartError, + LegacyStartNetworkCreateError, + LegacyStartVolumeCreateError, + LegacyStartVolumeInspectError, + legacyEnsureStartNetwork, + legacyEnsureStartVolume, + legacyStartContainer, + legacyStartVolumeExists, +} from "./container-lifecycle.ts"; +import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; + +let workdir: string; + +beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "supabase-legacy-start-container-lifecycle-")); +}); + +afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); +}); + +/** Matches the standing `mockSpawner` shape used across `legacy-docker-*.unit.test.ts` files, generalized to a per-call handler for multi-step orchestration (volume create -> container create -> container start). */ +function mockSpawner( + handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, +) { + const encoder = new TextEncoder(); + const spawned: Array> = []; + const spawnedOptions: Array<{ + readonly args: ReadonlyArray; + readonly env: Record | undefined; + readonly extendEnv: boolean | undefined; + }> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + if (command._tag === "StandardCommand") { + spawnedOptions.push({ + args, + env: command.options.env, + extendEnv: command.options.extendEnv, + }); + } + const result = handler(args); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + 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), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + get spawnedOptions() { + return spawnedOptions; + }, + }; +} + +const baseSpec: LegacyStartContainerSpec = { + image: "public.ecr.aws/supabase/postgres:15", + containerName: "supabase_db_proj", + env: {}, + binds: ["supabase_db_proj:/var/lib/postgresql/data", "/repo/backup.sql:/etc/backup.sql:ro"], + securityOpt: ["label:disable"], + networkId: "supabase_network_proj", + networkAliases: ["db"], + labels: {}, +}; + +function alwaysSucceed(stdout = "container-id-123\n") { + return mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 0, stdout }; + return { exitCode: 0 }; + }); +} + +describe("legacyStartContainer", () => { + it.live( + "merges project + compose labels, provisions named volumes, then creates and starts", + () => { + const mock = alwaysSucceed(); + return legacyStartContainer(mock.spawner, baseSpec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map((containerId) => { + expect(containerId).toBe("container-id-123"); + + const volumeCreate = mock.spawned.find( + (args) => args[0] === "volume" && args[1] === "create", + ); + expect(volumeCreate).toEqual([ + "volume", + "create", + "--label", + "com.supabase.cli.project=proj", + "--label", + "com.docker.compose.project=proj", + "supabase_db_proj", + ]); + + const create = mock.spawned.find((args) => args[0] === "create"); + expect(create).toContain("--label"); + expect(create).toContain("com.supabase.cli.project=proj"); + expect(create).toContain("com.docker.compose.project=proj"); + // Both the named-volume bind and the plain bind mount survive outside Bitbucket. + expect(create).toContain("supabase_db_proj:/var/lib/postgresql/data"); + expect(create).toContain("/repo/backup.sql:/etc/backup.sql:ro"); + expect(create).toContain("--security-opt"); + + const start = mock.spawned.find((args) => args[0] === "start"); + expect(start).toEqual(["start", "container-id-123"]); + + // Order matters: the volume must exist before `docker create` references it. + expect(mock.spawned.map((args) => args[0])).toEqual(["volume", "create", "start"]); + }), + ); + }, + ); + + it.live( + "stamps the container (but not its named volumes) with a com.supabase.cli.workdir label matching opts.workdir", + () => { + // Read back later by `legacyListContainerIdsAndNames` so a subsequent `stop`/rollback can + // reclaim `legacyCleanupStartSecrets`'s staged-secret directory from the CONTAINER's own + // label rather than the invoking caller's own cwd/`--workdir` (see that label's doc + // comment, `legacy-docker-ids.ts`). Volumes deliberately do NOT get this label — nothing + // ever reads it back off a volume, and the "merges project + compose labels..." test above + // already pins the volume's label list to exactly the two project-identity labels via + // `toEqual`, so a regression that leaked the workdir label onto volumes too would fail that + // test's exact-match assertion. + const mock = alwaysSucceed(); + return legacyStartContainer(mock.spawner, baseSpec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + const create = mock.spawned.find((args) => args[0] === "create"); + expect(create).toContain(`com.supabase.cli.workdir=${workdir}`); + }), + ); + }, + ); + + it.live( + "passes the spec's env values through the spawned process's own environment, extending it", + () => { + // `docker-create-args.ts` emits the key-only `-e KEY` form (never `-e KEY=value`) so + // secrets never appear in argv — Docker then resolves each key's value from the + // spawned `docker create` process's own environment. If `env`/`extendEnv` are ever + // dropped from the spawn call again, every `-e KEY` flag silently resolves to nothing + // and every container starts with none of its configured environment. + const mock = alwaysSucceed(); + const spec: LegacyStartContainerSpec = { + ...baseSpec, + env: { POSTGRES_PASSWORD: "s3cret", JWT_SECRET: "super-secret-value" }, + }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + const create = mock.spawnedOptions.find((entry) => entry.args[0] === "create"); + expect(create?.env).toEqual({ + POSTGRES_PASSWORD: "s3cret", + JWT_SECRET: "super-secret-value", + }); + expect(create?.extendEnv).toBe(true); + }), + ); + }, + ); + + it.live( + "excludes DOCKER_HOST from the spawned docker create process's own env, even though it's in spec.env (Vector's tcp/npipe daemon host)", + () => { + // `env`/`extendEnv: true` merge `spec.env` INTO the spawned `docker create` process's own + // environment (see the previous test) — but `DOCKER_HOST` configures which daemon the + // `docker`/`podman` CLI CLIENT itself talks to, not a container env var read via `-e KEY`. + // Letting a container-facing `DOCKER_HOST` (e.g. Vector's `http://host.docker.internal:...` + // for a tcp/npipe daemon host) leak into the spawned process's own env would hijack which + // daemon this `docker create` call itself targets, before the container even exists. + const mock = alwaysSucceed(); + const spec: LegacyStartContainerSpec = { + ...baseSpec, + env: { DOCKER_HOST: "http://host.docker.internal:2375", API_KEY: "s3cret" }, + }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + const create = mock.spawnedOptions.find((entry) => entry.args[0] === "create"); + expect(create?.env).toEqual({ API_KEY: "s3cret" }); + expect(create?.args).toContain("DOCKER_HOST=http://host.docker.internal:2375"); + }), + ); + }, + ); + + it.live( + "skips volume creation and drops the named-volume bind + security-opt under Bitbucket Pipelines", + () => { + const mock = alwaysSucceed(); + return legacyStartContainer(mock.spawner, baseSpec, { + projectId: "proj", + isBitbucketPipeline: true, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + expect(mock.spawned.some((args) => args[0] === "volume")).toBe(false); + + const create = mock.spawned.find((args) => args[0] === "create"); + expect(create).not.toContain("supabase_db_proj:/var/lib/postgresql/data"); + expect(create).toContain("/repo/backup.sql:/etc/backup.sql:ro"); + expect(create).not.toContain("--security-opt"); + }), + ); + }, + ); + + it.live("fails with LegacyStartVolumeCreateError before ever creating the container", () => { + const mock = mockSpawner((args) => { + if (args[0] === "volume") return { exitCode: 1, stderr: "no space left on device\n" }; + return { exitCode: 0, stdout: "should-not-be-created\n" }; + }); + return legacyStartContainer(mock.spawner, baseSpec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error.message).toBe("failed to create volume: no space left on device"); + expect(mock.spawned.some((args) => args[0] === "create")).toBe(false); + }), + ); + }); + + it.live("fails with LegacyStartContainerCreateError on a `docker create` non-zero exit", () => { + const mock = mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 1, stderr: "no such image\n" }; + return { exitCode: 0 }; + }); + const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error.message).toBe("failed to create docker container: no such image"); + expect(mock.spawned.some((args) => args[0] === "start")).toBe(false); + }), + ); + }); + + it.live("fails with LegacyStartContainerStartError, unmodified, on a plain start failure", () => { + const mock = mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 0, stdout: "abc\n" }; + if (args[0] === "start") return { exitCode: 1, stderr: "container is already stopped\n" }; + return { exitCode: 0 }; + }); + const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error.message).toBe( + 'failed to start docker container "supabase_db_proj": container is already stopped', + ); + }), + ); + }); + + it.live( + "appends a port-conflict suggestion, naming the container's first network alias, on a port-already-allocated failure", + () => { + const mock = mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 0, stdout: "abc\n" }; + if (args[0] === "start") { + return { + exitCode: 1, + stderr: + "Error response from daemon: driver failed programming external connectivity on endpoint supabase_db_proj: Bind for 0.0.0.0:5432 failed: port is already allocated\n", + }; + } + return { exitCode: 0 }; + }); + const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error.message).toContain('failed to start docker container "supabase_db_proj"'); + expect(error.message).toContain("0.0.0.0:5432"); + expect(error.message).toContain("db port in supabase/config.toml"); + }), + ); + }, + ); +}); + +describe("legacyStartContainer secretFiles", () => { + it.live( + "stages a secretFile as a mode-0644 HOST file (readable by non-root container users) under a mode-0700 deterministic, per-container directory, bind-mounts it read-only at the exact containerPath, keeps the raw content out of argv, and PERSISTS the file after a successful start so a `restartPolicy: unless-stopped` container can survive a host/daemon restart (CWE-214/522)", + () => { + let hostPath: string | undefined; + let modeAtCreateTime: number | undefined; + let dirModeAtCreateTime: number | undefined; + const mock = mockSpawner((args) => { + if (args[0] === "create") { + const bind = args.find((a) => a.endsWith(":/etc/kong/kong.yml:ro")); + if (bind !== undefined) { + hostPath = bind.slice(0, bind.length - ":/etc/kong/kong.yml:ro".length); + modeAtCreateTime = statSync(hostPath).mode & 0o777; + dirModeAtCreateTime = statSync(dirname(hostPath)).mode & 0o777; + } + return { exitCode: 0, stdout: "container-id-789\n" }; + } + return { exitCode: 0 }; + }); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map((containerId) => { + expect(containerId).toBe("container-id-789"); + + const create = mock.spawned.find((a) => a[0] === "create"); + expect(create?.some((a) => a.includes("super-secret-content"))).toBe(false); + + expect(hostPath).toBeDefined(); + expect(modeAtCreateTime).toBe(0o644); + expect(dirModeAtCreateTime).toBe(0o700); + expect(create).toContain(`${hostPath}:/etc/kong/kong.yml:ro`); + + // Deterministic — rooted in the project's own workdir, not an OS temp dir, and + // scoped by container name so sibling services never collide. + expect(hostPath).toBe( + join(workdir, "supabase", ".temp", "start-secrets", spec.containerName, "secret-0"), + ); + + // The staged file is left in place after a successful start: it must survive for + // the container's whole lifetime so a `restartPolicy: unless-stopped` restart (e.g. + // dockerd re-attaching this bind mount after a host reboot, long after this process + // has exited) can still read it. + expect(existsSync(hostPath ?? "")).toBe(true); + }), + ); + }, + ); + + it.live( + "keeps the staged secretFile at HOST mode 0644 even under a restrictive process umask (writeFile's `mode` is only a creation-time hint ANDed with the umask — without the explicit chmod, a 0077 umask would silently narrow the on-disk mode to 0600 and the non-root in-container reader would get EACCES)", + () => { + let hostPath: string | undefined; + let modeAtCreateTime: number | undefined; + const mock = mockSpawner((args) => { + if (args[0] === "create") { + const bind = args.find((a) => a.endsWith(":/etc/kong/kong.yml:ro")); + if (bind !== undefined) { + hostPath = bind.slice(0, bind.length - ":/etc/kong/kong.yml:ro".length); + modeAtCreateTime = statSync(hostPath).mode & 0o777; + } + return { exitCode: 0, stdout: "container-id-umask\n" }; + } + return { exitCode: 0 }; + }); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + // `it.live`'s test function runs inside `Effect.suspend`, so a plain JS try/finally around + // this `return` would restore the umask synchronously right after CONSTRUCTING the effect + // pipeline below, before it actually runs — long before the real write+chmod happens. + // `Effect.ensuring` is the effect-native equivalent: it sequences the restore to run only + // after this effect actually completes, on success, failure, or defect alike. + return Effect.sync(() => process.umask(0o077)).pipe( + Effect.flatMap((originalUmask) => + legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + expect(hostPath).toBeDefined(); + expect(modeAtCreateTime).toBe(0o644); + }), + Effect.ensuring(Effect.sync(() => process.umask(originalUmask))), + ), + ), + ); + }, + ); + + it.live( + "removes any pre-existing directory at the deterministic path before writing fresh files (self-healing across config changes)", + () => { + const dir = join(workdir, "supabase", ".temp", "start-secrets", baseSpec.containerName); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "stale-leftover-from-a-previous-config"), "old-content"); + + const mock = alwaysSucceed("container-id-stale\n"); + const spec: LegacyStartContainerSpec = { + ...baseSpec, + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "fresh-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + expect(readdirSync(dir)).toEqual(["secret-0"]); + expect(readFileSync(join(dir, "secret-0"), "utf8")).toBe("fresh-content"); + }), + ); + }, + ); + + it.live("cleans up the staged file even when `docker create` fails", () => { + let hostPath: string | undefined; + const mock = mockSpawner((args) => { + if (args[0] === "create") { + const bind = args.find((a) => a.endsWith(":/etc/kong/kong.yml:ro")); + hostPath = bind?.slice(0, bind.length - ":/etc/kong/kong.yml:ro".length); + return { exitCode: 1, stderr: "no such image\n" }; + } + return { exitCode: 0 }; + }); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + binds: [], + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(hostPath).toBeDefined(); + expect(existsSync(hostPath ?? "")).toBe(false); + }), + ); + }); + + it.live( + "cleans up the staged file when `docker create` succeeds but `docker start` fails", + () => { + let hostPath: string | undefined; + const mock = mockSpawner((args) => { + if (args[0] === "create") { + const bind = args.find((a) => a.endsWith(":/etc/kong/kong.yml:ro")); + hostPath = bind?.slice(0, bind.length - ":/etc/kong/kong.yml:ro".length); + return { exitCode: 0, stdout: "container-id-abc\n" }; + } + if (args[0] === "start") { + return { exitCode: 1, stderr: "container is already stopped\n" }; + } + return { exitCode: 0 }; + }); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + binds: [], + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(hostPath).toBeDefined(); + // The container never successfully started, so nothing depends on the file surviving. + expect(existsSync(hostPath ?? "")).toBe(false); + }), + ); + }, + ); + + it.live( + "cleans up the staged file on a SIGINT-style interruption mid-`docker create`, matching Go's no-orphaned-secrets guarantee", + () => { + // Go never writes these secrets to a host file at all (see `legacyStageStartSecretFiles`'s + // doc comment), so this is judged on its own correctness/security merits, not Go parity: + // a SIGINT landing after staging but before `docker create` returns must not leave a + // plaintext secret file behind indefinitely. `Effect.tapError` (the previous wiring) never + // sees a pure fiber interrupt — only `Effect.onError` does — same class of gap already + // fixed for the top-level bring-up rollback in `start.handler.ts`. + const createStarted = Deferred.makeUnsafe(); + const hangForever = Deferred.makeUnsafe(); + let hostPath: string | undefined; + const encoder = new TextEncoder(); + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + if (args[0] === "create") { + const bind = args.find((a) => a.endsWith(":/etc/kong/kong.yml:ro")); + hostPath = bind?.slice(0, bind.length - ":/etc/kong/kong.yml:ro".length); + yield* Deferred.succeed(createStarted, undefined); + // Never resolves on its own — only interruption ends this "process". + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(hangForever), + isRunning: Effect.succeed(true), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable([encoder.encode("")]), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + binds: [], + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return Effect.gen(function* () { + const fiber = yield* legacyStartContainer(spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(createStarted); + expect(hostPath).toBeDefined(); + expect(existsSync(hostPath ?? "")).toBe(true); + yield* Fiber.interrupt(fiber); + expect(existsSync(hostPath ?? "")).toBe(false); + }); + }, + ); + + it.live( + "maps a staging write failure to LegacyStartContainerCreateError, without ever invoking `docker create`", + () => { + const dir = join(workdir, "supabase", ".temp", "start-secrets", baseSpec.containerName); + // `dir` itself doesn't exist yet, so the self-healing `rm(dir, ...)` up front is a no-op — + // but its PARENT ("start-secrets") is a plain file instead of a directory, which forces + // `mkdir(dir, { recursive: true })` to fail with ENOTDIR. This exercises the staging + // try/catch's failure branch, which has no other way to fail deterministically from a + // unit test. + mkdirSync(dirname(dirname(dir)), { recursive: true }); + writeFileSync(dirname(dir), "not a directory"); + + const mock = mockSpawner(() => ({ exitCode: 0, stdout: "should-not-be-created\n" })); + const spec: LegacyStartContainerSpec = { + ...baseSpec, + binds: [], + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error.message).toMatch( + /^failed to create docker container: failed to stage container secret files: /, + ); + expect(mock.spawned.some((args) => args[0] === "create")).toBe(false); + // `mkdir` never got far enough to create anything at the per-container path — the + // inner catch's own `rm(dir, ...)` cleanup call is a no-op here, but still runs. + expect(existsSync(dir)).toBe(false); + }), + ); + }, + ); +}); + +describe("legacyEnsureStartNetwork", () => { + it.live("creates the network with labels", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", { + "com.supabase.cli.project": "proj", + "com.docker.compose.project": "proj", + }).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([ + [ + "network", + "create", + "--label", + "com.supabase.cli.project=proj", + "--label", + "com.docker.compose.project=proj", + "supabase_network_proj", + ], + ]); + }), + ); + }); + + it.live("treats an already-exists failure as success", () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: + "Error response from daemon: network with name supabase_network_proj already exists\n", + })); + return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", {}).pipe( + Effect.map(() => { + // Just needs to not fail — no return value to assert on. + }), + ); + }); + + it.live("fails with LegacyStartNetworkCreateError on any other failure", () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); + return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", {}).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartNetworkCreateError); + expect(error.message).toBe("failed to create docker network: permission denied"); + }), + ); + }); + + it.live.each(["default", "bridge", "host", "none"])( + "skips docker network create for the built-in %s network", + (networkId) => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "operation is not permitted on predefined host network", + })); + return legacyEnsureStartNetwork(mock.spawner, networkId, {}).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([]); + }), + ); + }, + ); +}); + +describe("legacyEnsureStartVolume", () => { + it.live("creates the named volume with labels", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", { + "com.supabase.cli.project": "proj", + }).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([ + ["volume", "create", "--label", "com.supabase.cli.project=proj", "supabase_db_proj"], + ]); + }), + ); + }); + + it.live("fails on any non-zero exit, with no already-exists tolerance", () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: + "a volume named supabase_db_proj already exists but was not created for the current specification\n", + })); + return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error.message).toBe( + "failed to create volume: a volume named supabase_db_proj already exists but was not created for the current specification", + ); + }), + ); + }); +}); + +describe("legacyStartVolumeExists", () => { + it.live("resolves true when `docker volume inspect` exits 0", () => { + const mock = mockSpawner(() => ({ exitCode: 0, stdout: "[]\n" })); + return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + Effect.map((exists) => { + expect(exists).toBe(true); + expect(mock.spawned).toEqual([["volume", "inspect", "supabase_db_proj"]]); + }), + ); + }); + + it.live('resolves false on a "no such volume" non-zero exit', () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Error: No such volume: supabase_db_proj\n", + })); + return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + Effect.map((exists) => { + expect(exists).toBe(false); + }), + ); + }); + + 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 legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + Effect.map((exists) => { + expect(exists).toBe(true); + }), + ); + }, + ); + + it.live("fails with LegacyStartVolumeInspectError when no runtime can be spawned", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn ENOENT", + }), + ), + ); + return legacyStartVolumeExists(spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartVolumeInspectError); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/db-setup.ts b/apps/cli/src/legacy/commands/start/lib/db-setup.ts new file mode 100644 index 0000000000..c9722c8cdd --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/db-setup.ts @@ -0,0 +1,650 @@ +/** + * Post-Postgres-health local database setup pipeline — a strict 1:1 port of Go's + * `SetupLocalDatabase` (`apps/cli-go/internal/db/start/start.go:359-381`), run once + * the `db` container's healthcheck passes on a FRESH volume (Go's `NoBackupVolume` + * gate, `start.go:184` — the caller decides whether to invoke this at all; see + * `legacyStartVolumeExists` in `./container-lifecycle.ts`). The single exported + * entry point, {@link legacyStartSetupLocalDatabase}, runs the exact Go call chain + * in order: + * + * 1. **`initSchema`** (`start.go:243-266`) — prints `Initialising schema...`, then + * branches on `db.major_version`: + * - **PG <= 14**: execs {@link LEGACY_START_DB_GLOBALS_SQL} (`utils.GlobalsSql`) + * then either {@link LEGACY_START_DB_INITIAL_SCHEMA_13_SQL} or + * {@link LEGACY_START_DB_INITIAL_SCHEMA_14_SQL} (`InitSchema14`, `start.go:256-266`, + * keyed on `major_version == 13`), each via `legacyExecSqlFile` against a temp file. + * - **PG >= 15** (`initSchema15`, `start.go:334-357`): runs up to three one-shot, + * foreground Docker jobs (`utils.DockerRunJob` = `DockerRunOnceWithStream`, a + * run-to-completion container on the SAME Docker network as `db` — Go's + * `DockerStart` defaults `NetworkMode` to `utils.NetId` when unset, + * `docker.go:379-383`), each gated on its own service's `enabled` flag and none + * of which touch `conn` directly: + * - `initRealtimeJob` (`start.go:268-295`) — reuses + * `../services/realtime.service.ts`'s `legacyBuildRealtimeEnv`, which builds + * the byte-identical env-var literal Go's own `initRealtimeJob` embeds + * verbatim (both are the same Go `Env` list, just addressed from two call + * sites: the long-running container and this one-shot job). + * - `initStorageJob` (`start.go:297-317`) — a DELIBERATELY SMALLER, differently- + * keyed env set than the long-running Storage container's own + * `storage.service.ts` builder (`PGRST_JWT_SECRET` not `AUTH_JWT_SECRET`, + * `STORAGE_FILE_BACKEND_PATH` not `FILE_STORAGE_BACKEND_PATH`, `REGION` not + * `STORAGE_S3_REGION`, no JWKS) — built locally, not reused. + * - `initAuthJob` (`start.go:319-332`) — ditto, a minimal env distinct from + * `gotrue.service.ts`'s full container builder. + * 2. **`ApplyApiPrivileges`** (`start.go:414-435`) — tri-state on + * `api.auto_expose_new_tables`: `true` is a no-op (keep the bundled initial-schema + * grants); unset/`false` execs {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL} + * (Go's inline `RevokeDefaultDataApiPrivilegesSql` constant, `start.go:405-412`) + * via a temp file, same as the schema SQL above. + * 3. **Vault upsert** (`start.go:390-393`) — `legacyUpsertVaultSecrets`, run BEFORE + * the custom-roles seed "so roles.sql can reference them" (Go's own comment). + * 4. **Custom-roles seed** (`start.go:394-398` + `pkg/migration/seed.go:84-97`) — + * prints "Seeding globals from roles.sql..." UNCONDITIONALLY, BEFORE checking + * whether `supabase/roles.sql` even exists (Go's `SeedGlobals` prints first, + * then attempts the read), then execs the file via `legacyExecSqlFile` only when + * it's actually present. A missing file is tolerated (Go's `errors.Is(err, + * os.ErrNotExist)` check, reproduced here as an existence check ahead of the read + * rather than a caught not-found error — see the call site's own comment for why); + * any other read/exec error propagates. + * 5. **`apply.MigrateAndSeed`** (`start.go:368`, via the already-ported + * `legacyMigrateAndSeed`) with `version: ""` — every pending migration, matching + * `SetupLocalDatabase`'s own call in the `start` context. + * + * Go's `initCurrentBranch` (`start.go:233-241`, writes `supabase/.branches/ + * _current_branch` = `"main"` if absent) is NOT part of this pipeline, even though + * it's exported from this module ({@link legacyStartInitCurrentBranch}): in Go it's + * called by `StartDatabase` (the caller of `SetupLocalDatabase`) UNCONDITIONALLY, + * regardless of `NoBackupVolume` (`start.go:184-189`) — unlike everything above, + * which only runs on a fresh volume. `start.handler.ts` calls it directly, outside + * the `isFreshVolume` gate that wraps {@link legacyStartSetupLocalDatabase}. + * + * Go's best-effort `pgcache.TryCacheMigrationsCatalog` warning (`start.go:371-379`) + * is intentionally NOT ported — same accepted, documented divergence as + * `db/reset/reset.handler.ts`'s identical comment (no output impact either way). + * + * This module also duplicates ONE config-load pass: `legacyCheckDbToml` is called + * internally (not threaded in from the caller) to resolve `[db.vault]`, `[db.seed]`, + * `db.migrations.enabled`, and the effective `api.auto_expose_new_tables` tri-state — + * the same accepted duplication `db start`'s own handler already takes + * independently of the top-level `start` command's own config resolution (see + * `commands/db/start/start.handler.ts:40`). + */ + +import type { ProjectConfig } from "@supabase/config"; +import { Data, Effect, type FileSystem, Option, type Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; +import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; +import { legacyMigrateAndSeed } from "../../../shared/legacy-migrate-and-seed.ts"; +import { + LegacyMigrationApplyError, + legacyExecSqlFile, +} from "../../../shared/legacy-migration-apply.ts"; +import type { LegacyMigrationSeedError } from "../../../shared/legacy-seed.ts"; +import { ramInBytes } from "../../../shared/legacy-size-units.ts"; +import { + LegacyMigrationVaultError, + legacyUpsertVaultSecrets, +} from "../../../shared/legacy-vault.ts"; +import { LEGACY_REALTIME_TENANT_ID, legacyBuildRealtimeEnv } from "../services/realtime.service.ts"; +import { LEGACY_START_DB_GLOBALS_SQL } from "../templates/db-globals.sql.ts"; +import { LEGACY_START_DB_INITIAL_SCHEMA_13_SQL } from "../templates/db-initial-schema-13.sql.ts"; +import { LEGACY_START_DB_INITIAL_SCHEMA_14_SQL } from "../templates/db-initial-schema-14.sql.ts"; +import { + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "./internal-db-connection.ts"; + +/** + * Go's inline `RevokeDefaultDataApiPrivilegesSql` constant (`start.go:405-412`) — + * NOT a `//go:embed` file (unlike the three large SQL templates), so transcribed + * directly here rather than as a sibling `templates/*.sql.ts` module. + */ +const LEGACY_START_REVOKE_API_PRIVILEGES_SQL = ` +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; +`; + +/** + * A SQL exec (schema/globals/API-privileges) or one-shot service-migration Docker + * job failed, or the scratch temp directory/file could not be created. The Docker + * job branch's message mirrors Go's `DockerRunOnceWithStream` failure shape + * (`errors.Errorf("error running container: %w", err)`, `apps/cli-go/internal/ + * utils/docker.go:469-487,559-591` — Go discards the container's own stdout/stderr + * outside `--debug`, so only the exit code is meaningful here too). + */ +export class LegacyStartDbSetupError extends Data.TaggedError("LegacyStartDbSetupError")<{ + readonly message: string; +}> {} + +/** Every failure {@link legacyStartSetupLocalDatabase} can produce. */ +export type LegacyStartSetupLocalDatabaseError = + | LegacyDbConfigLoadError + | LegacyStartDbSetupError + | LegacyMigrationVaultError + | LegacyMigrationApplyError + | LegacyMigrationSeedError; + +/** Already-resolved Docker images for the three PG15+ one-shot migrate jobs (`initSchema15`'s `initJobs`). */ +export interface LegacyStartDbSetupImages { + /** `utils.Config.Realtime.Image`, resolved by the caller (not part of the decoded `ProjectConfig` schema — `toml:"-"`). */ + readonly realtime: string; + /** `utils.Config.Storage.Image`, ditto. */ + readonly storage: string; + /** `utils.Config.Auth.Image`, ditto. */ + readonly auth: string; +} + +/** Input to {@link legacyStartSetupLocalDatabase}. */ +export interface LegacyStartSetupLocalDatabaseInput { + /** + * An already-open session to the local Postgres database, dialed the same way + * Go's `ConnectLocalPostgres(ctx, pgconn.Config{})` does (`internal/utils/ + * connect.go:144-167`): the HOST-facing address (`legacyGetHostname()` + + * `db.port`, user `postgres`, `isLocal: true`) — the SAME shape `legacy-db- + * config.layer.ts`'s own `--local` branch already dials (`legacy-db-config. + * layer.ts:518-529`). This is deliberately NOT the internal Docker-network `db` + * container address the PG15+ one-shot jobs below connect through (see + * `networkId`/`projectId`) — the two addressing schemes are independent, exactly + * like Go's `conn` (host-facing) vs. `host` parameter (`utils.DbId`) in + * `SetupDatabase(ctx, conn, utils.DbId, w, fsys)`. + */ + readonly session: LegacyDbSession; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + /** The Supabase project root (parent of `supabase/`). */ + readonly workdir: string; + /** The caller's already-resolved, effective config (env overrides already applied). */ + readonly config: ProjectConfig; + /** `db.major_version` (13-17) — Go's `utils.Config.Db.MajorVersion`, resolved by the caller once, ahead of the `db` container's own image tag selection. */ + readonly majorVersion: number; + /** Go's `Config.ProjectId`, already sanitized (`legacySanitizeProjectId`) — derives the `db` container's internal Docker name for the PG15+ one-shot jobs (`legacyServiceContainerName("db", projectId)`, Go's `utils.DbId`). */ + readonly projectId: string; + /** The `start` run's Docker network id (Go's `utils.NetId` or the `--network-id` override) — every PG15+ one-shot job joins it, matching `DockerStart`'s own default (`docker.go:379-383`). */ + readonly networkId: string; + /** `LegacyLocalConfigValues.dbUrl` — reused (not recomputed) to derive the internal DB password via `legacyStartInternalDbPassword`, matching every other `start/services/*.service.ts` builder. */ + readonly dbUrl: string; + /** `LegacyLocalConfigValues.jwtSecret`. */ + readonly jwtSecret: string; + /** `legacyResolveLocalJwks`'s resolved JWKS JSON string (only read when `realtime.enabled`) — already built by the caller, not recomputed here. */ + readonly jwks: string; + /** `LegacyLocalConfigValues.apiUrl` — the auth job's `API_EXTERNAL_URL` falls back to this, `/auth/v1`-suffixed, only when {@link authExternalUrl} is unset. */ + readonly apiUrl: string; + /** + * Raw `auth.external_url` (already `SUPABASE_AUTH_EXTERNAL_URL`-overridden + * by the caller) — Go's `Config.Auth.ExternalUrl`/`AuthExternalURL()` + * (`pkg/config/config.go:543-545`, `auth.go:401-405`): an explicit value + * wins over the `apiUrl`-derived fallback, same as `gotrue.service.ts`'s + * `LegacyBuildGotrueEnvInput.authExternalUrl` for the long-running + * container — this one-shot job must resolve to the SAME value so a fresh + * database's auth migration never disagrees with the container it's + * migrating for. + */ + readonly authExternalUrl?: string; + /** + * `LegacyLocalConfigValues.authSiteUrl` (already `SUPABASE_AUTH_SITE_URL`- + * overridden by the caller) — Go's `initAuthJob` reads the same overridden + * `utils.Config.Auth.SiteUrl` the long-running GoTrue container does + * (`apps/cli-go/internal/db/start/start.go:327`, `internal/start/ + * start.go:1365`), so this one-shot job must resolve to the SAME value, + * not the raw `config.auth.site_url` — same rationale as + * {@link authExternalUrl} above. + */ + readonly siteUrl: string; + /** `LegacyLocalConfigValues.anonKey`. */ + readonly anonKey: string; + /** `LegacyLocalConfigValues.serviceRoleKey`. */ + readonly serviceRoleKey: string; + /** Go's `utils.Config.Storage.TargetMigration` (`toml:"-"`, resolved from a version-pin file) — the caller passes `""` when absent, matching Go's zero-value default. */ + readonly storageTargetMigration: string; + readonly images: LegacyStartDbSetupImages; +} + +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + +/** + * Writes `sql` to `/` and execs it via `legacyExecSqlFile` + * (Go's `migration.NewMigrationFromReader(strings.NewReader(sql))` + + * `file.ExecBatch(ctx, conn)` on an in-memory string — there is no on-disk file in + * Go at all; this port needs one only because `legacyExecSqlFile` reads from the + * filesystem like every other `execMigrationBatch` caller). + */ +const legacyExecSqlConstant = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + tmpDir: string, + filename: string, + sql: string, +) { + const filePath = path.join(tmpDir, filename); + yield* fs.writeFileString(filePath, sql).pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed to write ${filename}: ${errMessage(error)}`, + }), + ), + ); + yield* legacyExecSqlFile( + session, + fs, + path, + filePath, + (message) => new LegacyStartDbSetupError({ message }), + ); +}); + +/** + * Port of Go's `InitSchema14` (`start.go:256-266`): execs + * {@link LEGACY_START_DB_GLOBALS_SQL} then the major-version-appropriate initial + * schema. Only reached for `majorVersion <= 14` (the caller, `legacyStartInitSchema`, + * gates on that). + */ +const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + tmpDir: string, + majorVersion: number, +) { + yield* legacyExecSqlConstant( + session, + fs, + path, + tmpDir, + "globals.sql", + LEGACY_START_DB_GLOBALS_SQL, + ); + const schemaSql = + majorVersion === 13 + ? LEGACY_START_DB_INITIAL_SCHEMA_13_SQL + : LEGACY_START_DB_INITIAL_SCHEMA_14_SQL; + yield* legacyExecSqlConstant(session, fs, path, tmpDir, "initial-schema.sql", schemaSql); +}); + +/** + * Runs one PG15+ one-shot service-migration job to completion (Go's + * `utils.DockerRunJob` = `DockerRunOnceWithStream`, `docker.go:457-459,469-487`): + * foreground, same Docker network as `db`, no entrypoint override (Go's plain + * `Cmd` field), stdout discarded and stderr not teed (Go discards both outside + * `--debug` — `utils.GetDebugLogger()`, `logger.go:10-15`). A non-zero exit fails + * with the same shape as Go's `error running container: `. + */ +const legacyRunStartMigrateJob = Effect.fnUntraced(function* (opts: { + readonly image: string; + readonly env: Readonly>; + readonly cmd: ReadonlyArray; + readonly networkId: string; +}) { + const docker = yield* LegacyDockerRun; + const runtimeInfo = yield* RuntimeInfo; + // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal: + // host-gateway` extra host for every container it starts (`docker_linux.go`), + // including one-shot jobs routed through the same `DockerStart` path. + const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + const runOpts: LegacyDockerRunOpts = { + image: opts.image, + cmd: opts.cmd, + env: opts.env, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts, + network: { _tag: "named", name: opts.networkId }, + // `opts.image` is already fully resolved (`start.handler.ts`'s `resolveImage`, which + // threads `projectEnvValues` through `legacyEnsureImagesCached`) — this layer's own + // ambient-only resolver must not re-resolve it. See `LegacyDockerRunOpts. + // skipImageResolve`'s doc comment. + skipImageResolve: true, + }; + const result = yield* docker + .runCapture(runOpts) + .pipe(Effect.mapError((cause) => new LegacyStartDbSetupError({ message: cause.message }))); + if (result.exitCode !== 0) { + return yield* Effect.fail( + new LegacyStartDbSetupError({ message: `error running container: exit ${result.exitCode}` }), + ); + } +}); + +/** Go's `initStorageJob` env (`start.go:297-317`) — deliberately distinct from `storage.service.ts`'s full container env, see this module's header. */ +function legacyStartStorageMigrateEnv(input: { + readonly targetMigration: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly jwtSecret: string; + readonly dbHost: string; + readonly dbPassword: string; + readonly fileSizeLimit: ProjectConfig["storage"]["file_size_limit"]; +}): Record { + return { + DB_INSTALL_ROLES: "false", + DB_MIGRATIONS_FREEZE_AT: input.targetMigration, + ANON_KEY: input.anonKey, + SERVICE_KEY: input.serviceRoleKey, + PGRST_JWT_SECRET: input.jwtSecret, + DATABASE_URL: legacyStartInternalDbUrl( + "supabase_storage_admin", + input.dbHost, + input.dbPassword, + ), + FILE_SIZE_LIMIT: String(ramInBytes(input.fileSizeLimit)), + STORAGE_BACKEND: "file", + STORAGE_FILE_BACKEND_PATH: "/mnt", + TENANT_ID: "stub", + // TODO (matches Go's own TODO, `start.go:311`): https://github.com/supabase/storage-api/issues/55 + REGION: "stub", + GLOBAL_S3_BUCKET: "stub", + }; +} + +/** Go's `initAuthJob` env (`start.go:319-332`) — deliberately distinct from `gotrue.service.ts`'s full container env, see this module's header. */ +function legacyStartAuthMigrateEnv(input: { + readonly apiUrl: string; + readonly authExternalUrl: string | undefined; + readonly siteUrl: ProjectConfig["auth"]["site_url"]; + readonly jwtSecret: string; + readonly dbHost: string; + readonly dbPassword: string; +}): Record { + // Go's `AuthExternalURL()` (`pkg/config/config.go:543-545` -> `auth.GetExternalURL`): + // an explicit `auth.external_url` wins outright; only derive from `apiUrl` + // when it's unset — matching `gotrue.service.ts`'s identical preference + // chain for the long-running container. + const authExternalUrl = + input.authExternalUrl !== undefined && input.authExternalUrl.length > 0 + ? input.authExternalUrl + : `${input.apiUrl.replace(/\/+$/, "")}/auth/v1`; + return { + API_EXTERNAL_URL: authExternalUrl, + GOTRUE_LOG_LEVEL: "error", + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_DB_DATABASE_URL: legacyStartInternalDbUrl( + "supabase_auth_admin", + input.dbHost, + input.dbPassword, + ), + GOTRUE_SITE_URL: input.siteUrl, + GOTRUE_JWT_SECRET: input.jwtSecret, + }; +} + +/** + * Port of Go's `initSchema15` (`start.go:334-357`): up to three one-shot + * migrate jobs, each gated on its own service's `enabled` flag, run in Go's + * fixed order (realtime, storage, auth). + */ +const legacyStartInitSchema15 = Effect.fnUntraced(function* ( + input: LegacyStartSetupLocalDatabaseInput, +) { + const dbHost = legacyServiceContainerName("db", input.projectId); + const dbPassword = legacyStartInternalDbPassword(input.dbUrl); + + if (input.config.realtime.enabled) { + yield* legacyRunStartMigrateJob({ + image: input.images.realtime, + networkId: input.networkId, + env: legacyBuildRealtimeEnv({ + ipVersion: input.config.realtime.ip_version, + maxHeaderLength: input.config.realtime.max_header_length, + dbHost, + dbPassword, + jwtSecret: input.jwtSecret, + jwks: input.jwks, + }), + cmd: [ + "/app/bin/realtime", + "eval", + `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`, + ], + }); + } + if (input.config.storage.enabled) { + // `legacyStartStorageMigrateEnv` parses `storage.file_size_limit` via + // `ramInBytes`, which throws on a malformed value — a plain synchronous + // throw here would become an uncaught Effect defect (`Effect.tapError`'s + // rollback trigger below only fires on typed `Fail` causes, never `Die` + // ones), leaking Postgres's already-created container/network/volume. + // Go fails this same malformed value at TOML-decode time, before any + // Docker work (`sizeInBytes.UnmarshalText`, `pkg/config/config.go:41-47`) + // — this can't be replicated literally here since Postgres is already up + // by this step, but surfacing it as a typed `LegacyStartDbSetupError` so + // rollback actually runs is the achievable equivalent, matching the same + // fix already applied to `resolveDbHealthTimeoutSeconds` and the + // long-running Storage container's own file-size-limit parsing + // (`start.handler.ts`). + const storageEnv = yield* Effect.try({ + try: () => + legacyStartStorageMigrateEnv({ + targetMigration: input.storageTargetMigration, + anonKey: input.anonKey, + serviceRoleKey: input.serviceRoleKey, + jwtSecret: input.jwtSecret, + dbHost, + dbPassword, + fileSizeLimit: input.config.storage.file_size_limit, + }), + catch: (cause) => + new LegacyStartDbSetupError({ + message: `invalid config for storage: ${errMessage(cause)}`, + }), + }); + yield* legacyRunStartMigrateJob({ + image: input.images.storage, + networkId: input.networkId, + env: storageEnv, + cmd: ["node", "dist/scripts/migrate-call.js"], + }); + } + if (input.config.auth.enabled) { + yield* legacyRunStartMigrateJob({ + image: input.images.auth, + networkId: input.networkId, + env: legacyStartAuthMigrateEnv({ + apiUrl: input.apiUrl, + authExternalUrl: input.authExternalUrl, + siteUrl: input.siteUrl, + jwtSecret: input.jwtSecret, + dbHost, + dbPassword, + }), + cmd: ["gotrue", "migrate"], + }); + } +}); + +/** + * Port of Go's `initSchema` (`start.go:243-254`): prints the banner line once, + * then branches on PG major version — unconditionally, for BOTH branches, exactly + * matching Go's `fmt.Fprintln(w, "Initialising schema...")` running before the + * `if utils.Config.Db.MajorVersion <= 14` check. + */ +const legacyStartInitSchema = Effect.fnUntraced(function* ( + input: LegacyStartSetupLocalDatabaseInput, + tmpDir: string, +) { + const output = yield* Output; + yield* output.raw("Initialising schema...\n", "stderr"); + if (input.majorVersion <= 14) { + yield* legacyStartInitSchemaPre15( + input.session, + input.fs, + input.path, + tmpDir, + input.majorVersion, + ); + return; + } + yield* legacyStartInitSchema15(input); +}); + +/** + * Port of Go's `ApplyApiPrivileges` (`start.go:414-435`): tri-state on + * `api.auto_expose_new_tables` — `true` keeps the bundled initial-schema grants + * (no-op); unset/`false` execs {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL}. Runs + * regardless of PG major version (unlike `initSchema`, this always execs SQL over + * `session` directly — it is never part of the PG15+ one-shot Docker jobs). + */ +const legacyStartApplyApiPrivileges = Effect.fnUntraced(function* ( + input: LegacyStartSetupLocalDatabaseInput, + tmpDir: string, + autoExposeNewTables: Option.Option, +) { + if (Option.isSome(autoExposeNewTables) && autoExposeNewTables.value) return; + yield* legacyExecSqlConstant( + input.session, + input.fs, + input.path, + tmpDir, + "revoke-api-privileges.sql", + LEGACY_START_REVOKE_API_PRIVILEGES_SQL, + ); +}); + +/** + * Port of Go's `initCurrentBranch` (`start.go:233-241`): writes + * `supabase/.branches/_current_branch` = `"main"` (Go's `CurrBranchPath`, + * `apps/cli-go/internal/utils/misc.go:99` = `filepath.Join(SupabaseDirPath, + * ".branches", "_current_branch")`) only if it doesn't already exist. No existing + * TS constant for this path — `legacy/commands/db/branch/*` are Management-API + * cloud-branch commands, unrelated to this local file — so it's inlined here, + * the only current consumer (per "Hoist Before You Duplicate"). Exported (rather + * than folded into {@link legacyStartSetupLocalDatabase}) because Go calls it + * unconditionally, not just on a fresh volume — see this module's header. + */ +export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +) { + const currentBranchPath = path.join(workdir, "supabase", ".branches", "_current_branch"); + const exists = yield* fs.exists(currentBranchPath).pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed init current branch: ${errMessage(error)}`, + }), + ), + ); + if (exists) return; + yield* fs.makeDirectory(path.dirname(currentBranchPath), { recursive: true }).pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed init current branch: ${errMessage(error)}`, + }), + ), + ); + yield* fs.writeFileString(currentBranchPath, "main").pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed init current branch: ${errMessage(error)}`, + }), + ), + ); +}); + +/** + * Runs the full `SetupLocalDatabase`-equivalent sequence — see this module's + * header for the exact Go call chain and line-range citations. Call once, right + * after the `db` container's healthcheck passes on a fresh volume (Go's + * `NoBackupVolume` gate); the caller decides that gating, this function performs + * no health/readiness checks of its own. + */ +export const legacyStartSetupLocalDatabase = ( + input: LegacyStartSetupLocalDatabaseInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError, + Output | LegacyDockerRun | RuntimeInfo +> => + Effect.gen(function* () { + const { session, fs, path, workdir } = input; + + const toml = yield* legacyCheckDbToml(fs, path, workdir); + + // SetupDatabase: initSchema -> ApplyApiPrivileges (start.go:383-389). + yield* Effect.scoped( + Effect.gen(function* () { + const tmpDir = yield* fs + .makeTempDirectoryScoped({ prefix: "supabase-start-db-setup-" }) + .pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed to create temp directory: ${errMessage(error)}`, + }), + ), + ); + yield* legacyStartInitSchema(input, tmpDir); + yield* legacyStartApplyApiPrivileges(input, tmpDir, toml.baseline.apiAutoExposeNewTables); + }), + ); + + // "Create vault secrets first so roles.sql can reference them" (start.go:390). + yield* legacyUpsertVaultSecrets(session, toml.vault); + + // Custom-roles seed (start.go:394-398, pkg/migration/seed.go:84-97): Go's + // `SeedGlobals` prints "Seeding globals from roles.sql..." BEFORE attempting + // to read the file, then tolerates a missing file (`errors.Is(err, + // os.ErrNotExist)`); any other read/exec error propagates. Reproduced here as + // an unconditional print followed by an existence check ahead of the read + // (via `legacyExecSqlFile`, not `legacySeedGlobals` — reusing `legacySeedGlobals` + // would need the missing-file case to unwind through `execMigrationBatch`'s + // shared, flattened error-mapping contract in `legacy-migration-apply.ts`, + // which every other caller of that file also relies on and which is out of + // scope to change here) rather than a caught not-found error, since there is + // no meaningful TOCTOU concern in this CLI context. + const customRolesPath = path.join(workdir, "supabase", "roles.sql"); + const output = yield* Output; + yield* output.raw(`Seeding globals from ${path.basename(customRolesPath)}...\n`, "stderr"); + const rolesExist = yield* fs.exists(customRolesPath).pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed to check roles.sql: ${errMessage(error)}`, + }), + ), + ); + if (rolesExist) { + yield* legacyExecSqlFile( + session, + fs, + path, + customRolesPath, + (message) => new LegacyStartDbSetupError({ message }), + ); + } + + // apply.MigrateAndSeed(ctx, "", conn, fsys) — empty version = every pending + // migration, matching `SetupLocalDatabase`'s own call in the `start` context + // (start.go:368). + yield* legacyMigrateAndSeed(session, fs, path, workdir, "", { + migrationsEnabled: toml.migrationsEnabled, + seed: toml.seed, + }); + + // Go's best-effort pgcache catalog warning (`pgcache.TryCacheMigrationsCatalog`, + // start.go:371-379) is not ported (no output impact) — same accepted, documented + // divergence as `db/reset/reset.handler.ts`. + // + // `initCurrentBranch` (start.go:233-241) is NOT called here — see this + // module's header for why it moved to the caller instead. + }); diff --git a/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts new file mode 100644 index 0000000000..8f2decfc6f --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts @@ -0,0 +1,523 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProjectConfig } from "@supabase/config"; +import { ProjectConfigSchema } from "@supabase/config"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; + +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; +import { LegacyDockerRunError } from "../../../shared/legacy-docker-run.errors.ts"; +import { + LegacyStartDbSetupError, + legacyStartInitCurrentBranch, + legacyStartSetupLocalDatabase, + type LegacyStartSetupLocalDatabaseInput, +} from "./db-setup.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +/** + * Fingerprints unique to each transcribed SQL constant — see `db-setup.ts`'s + * templates. No trailing `;`: `legacySplitAndTrim` strips it from every + * executed statement before `session.exec` sees it. + * + * `GLOBALS`/`SCHEMA_13`/`REVOKE_PRIVILEGES` are checked as SUBSTRINGS: each is + * embedded inside a larger executed block (a preceding comment, a `DO`-style + * conditional, or the sibling `alter default privileges` line). `SCHEMA_14` + * is checked with `.endsWith` instead — `CREATE SCHEMA IF NOT EXISTS graphql` + * is itself preceded by a comment block (so a plain equality check would + * fail), but a naive substring check would also match 14.sql's unrelated + * `CREATE SCHEMA IF NOT EXISTS graphql_public` statement, since `graphql` is + * a prefix of `graphql_public`. + */ +const GLOBALS_FINGERPRINT = "CREATE ROLE anon"; +const SCHEMA_13_FINGERPRINT = + "CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION"; +const SCHEMA_14_FINGERPRINT_SUFFIX = "CREATE SCHEMA IF NOT EXISTS graphql"; +const REVOKE_PRIVILEGES_FINGERPRINT = + "revoke execute on functions from anon, authenticated, service_role"; + +function fakeSession() { + const calls: Array<{ kind: "exec" | "query"; sql: string; params?: ReadonlyArray }> = []; + const session: LegacyDbSession = { + exec: (sql) => + Effect.sync(() => { + calls.push({ kind: "exec", sql }); + }), + query: (sql, params) => + Effect.sync(() => { + calls.push({ kind: "query", sql, params }); + return []; + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +function mockDockerRun(opts: { exitCode?: number } = {}) { + const runs: Array = []; + const layer = Layer.succeed(LegacyDockerRun, { + run: () => Effect.succeed(opts.exitCode ?? 0), + runCapture: (runOpts) => { + runs.push(runOpts); + return Effect.succeed({ + exitCode: opts.exitCode ?? 0, + stdout: new Uint8Array(), + stderr: "", + }); + }, + runStream: () => Effect.succeed({ exitCode: opts.exitCode ?? 0, stderr: "" }), + }); + return { layer, runs }; +} + +function mockDockerRunFails() { + const layer = Layer.succeed(LegacyDockerRun, { + run: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), + runCapture: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), + runStream: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), + }); + return { layer }; +} + +function makeWorkdir(): string { + return mkdtempSync(join(tmpdir(), "legacy-db-setup-")); +} + +function writeConfigToml(workdir: string, content: string): void { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), content); +} + +const defaultConfig: ProjectConfig = decodeConfig({}); + +function baseInput( + workdir: string, + session: LegacyDbSession, + overrides: Partial = {}, +): Omit { + return { + session, + workdir, + config: defaultConfig, + majorVersion: 17, + projectId: "proj", + networkId: "supabase_network_proj", + dbUrl: "postgresql://postgres:postgrespassword@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + apiUrl: "http://127.0.0.1:54321", + siteUrl: defaultConfig.auth.site_url, + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + storageTargetMigration: "", + images: { + realtime: "public.ecr.aws/supabase/realtime:v2.34.7", + storage: "public.ecr.aws/supabase/storage-api:v1.0.0", + auth: "public.ecr.aws/supabase/gotrue:v2.170.0", + }, + ...overrides, + }; +} + +const run = ( + input: Omit, + out: ReturnType, + docker: ReturnType | ReturnType, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyStartSetupLocalDatabase({ ...input, fs, path }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + docker.layer, + mockRuntimeInfo({ platform: "darwin" }), + ), + ), + ); + +describe("legacyStartSetupLocalDatabase", () => { + describe("PG <= 14 vs PG >= 15 schema branch", () => { + it.effect("PG14: execs globals + the PG14 initial schema, runs no one-shot docker jobs", () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(true); + expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(true); + expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(false); + expect(docker.runs.length).toBe(0); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("PG13: execs globals + the PG13 initial schema, not the PG14 one", () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 13 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(true); + expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(true); + expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(false); + expect(docker.runs.length).toBe(0); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("PG15+: runs one-shot docker jobs, execs no schema SQL at all", () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 17 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(false); + expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(false); + expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(false); + // Default config: realtime, storage, and auth are all enabled. + expect(docker.runs.length).toBe(3); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect('prints "Initialising schema..." once, for either branch', () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 17 }), out, docker).pipe( + Effect.map(() => { + const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); + expect(banner.length).toBe(1); + expect(banner[0]?.stream).toBe("stderr"); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + }); + + describe("PG15+ one-shot job gating", () => { + it.effect("gates each job independently on its own service's enabled flag", () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: true }, + storage: { enabled: false }, + auth: { enabled: true }, + }); + return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(2); + const images = docker.runs.map((r) => r.image); + expect(images).toContain("public.ecr.aws/supabase/realtime:v2.34.7"); + expect(images).toContain("public.ecr.aws/supabase/gotrue:v2.170.0"); + expect(images).not.toContain("public.ecr.aws/supabase/storage-api:v1.0.0"); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("runs nothing when all three services are disabled", () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: false }, + storage: { enabled: false }, + auth: { enabled: false }, + }); + return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(0); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect( + "the realtime job's env matches `legacyBuildRealtimeEnv` on the internal db address + jwks", + () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); + return run( + baseInput(workdir, session, { + majorVersion: 15, + config, + projectId: "myproj", + jwks: '{"keys":["stub"]}', + }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(1); + const job = docker.runs[0]!; + expect(job.cmd[0]).toBe("/app/bin/realtime"); + expect(job.cmd[1]).toBe("eval"); + expect(job.cmd[2]).toContain('Realtime.Tenants.health_check("realtime-dev")'); + expect(job.env["DB_HOST"]).toBe("supabase_db_myproj"); + expect(job.env["API_JWT_JWKS"]).toBe('{"keys":["stub"]}'); + expect(job.network).toEqual({ _tag: "named", name: "supabase_network_proj" }); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + + it.effect( + "the auth job's env derives API_EXTERNAL_URL from apiUrl and carries site_url + jwt secret", + () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ realtime: { enabled: false }, storage: { enabled: false } }); + return run( + baseInput(workdir, session, { + majorVersion: 15, + config, + apiUrl: "http://127.0.0.1:54321/", + }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(1); + const job = docker.runs[0]!; + expect(job.cmd).toEqual(["gotrue", "migrate"]); + expect(job.env["API_EXTERNAL_URL"]).toBe("http://127.0.0.1:54321/auth/v1"); + expect(job.env["GOTRUE_SITE_URL"]).toBe(config.auth.site_url); + expect(job.env["GOTRUE_JWT_SECRET"]).toBe( + "super-secret-jwt-token-with-at-least-32-characters-long", + ); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + + it.effect( + "the auth job's GOTRUE_SITE_URL reflects the caller's resolved siteUrl, not the raw config value", + () => { + // `siteUrl` is already SUPABASE_AUTH_SITE_URL-overridden by the caller + // (`start.handler.ts`'s `values.authSiteUrl`) — the one-shot auth + // migration job must agree with the long-running GoTrue container, + // not fall back to reading the un-overridden `config.auth.site_url`. + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: false }, + storage: { enabled: false }, + auth: { site_url: "http://raw-config-value.example" }, + }); + return run( + baseInput(workdir, session, { + majorVersion: 15, + config, + apiUrl: "http://127.0.0.1:54321/", + siteUrl: "http://env-overridden-value.example", + }), + out, + docker, + ).pipe( + Effect.map(() => { + const job = docker.runs[0]!; + expect(job.env["GOTRUE_SITE_URL"]).toBe("http://env-overridden-value.example"); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + + it.effect("a non-zero exit from a one-shot job fails the whole pipeline", () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun({ exitCode: 1 }); + const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); + return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartDbSetupError); + expect((error as LegacyStartDbSetupError).message).toBe( + "error running container: exit 1", + ); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + }); + + describe("ApplyApiPrivileges tri-state", () => { + it.effect("auto_expose_new_tables = true is a no-op (no revoke SQL exec'd)", () => { + const workdir = makeWorkdir(); + writeConfigToml(workdir, "[api]\nauto_expose_new_tables = true\n"); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("auto_expose_new_tables = false execs the revoke SQL", () => { + const workdir = makeWorkdir(); + writeConfigToml(workdir, "[api]\nauto_expose_new_tables = false\n"); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect( + "unset (no config.toml at all) matches the false behavior — execs the revoke SQL", + () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + }); + + describe("vault upsert + custom-roles seed", () => { + it.effect("upserts vault secrets before seeding supabase/roles.sql", () => { + const workdir = makeWorkdir(); + writeConfigToml(workdir, '[db.vault]\nmy_secret = "shh"\n'); + writeFileSync( + join(workdir, "supabase", "roles.sql"), + "grant select on all tables in schema public to custom_role;", + ); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const vaultCallIndex = calls.findIndex( + (c) => c.kind === "query" && c.sql.includes("vault.create_secret"), + ); + const rolesCallIndex = calls.findIndex( + (c) => c.kind === "exec" && c.sql.includes("custom_role"), + ); + expect(vaultCallIndex).toBeGreaterThanOrEqual(0); + expect(rolesCallIndex).toBeGreaterThanOrEqual(0); + expect(vaultCallIndex).toBeLessThan(rolesCallIndex); + expect(out.rawChunks.map((c) => c.text)).toContain("Seeding globals from roles.sql...\n"); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect( + "prints the seed message even when supabase/roles.sql is absent, and does not error", + () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + // Go's `SeedGlobals` prints before attempting the read (`pkg/migration/ + // seed.go:84-97`) — a missing roles.sql is tolerated, not skipped. + expect(out.rawChunks.map((c) => c.text)).toContain( + "Seeding globals from roles.sql...\n", + ); + const rolesCallIndex = calls.findIndex( + (c) => c.kind === "exec" && c.sql.includes("custom_role"), + ); + expect(rolesCallIndex).toBe(-1); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + }); +}); + +describe("legacyStartInitCurrentBranch", () => { + it.effect('writes supabase/.branches/_current_branch = "main" when absent', () => { + const workdir = makeWorkdir(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacyStartInitCurrentBranch(fs, path, workdir); + const content = yield* fs.readFileString( + join(workdir, "supabase", ".branches", "_current_branch"), + ); + expect(content).toBe("main"); + }).pipe( + Effect.provide(BunServices.layer), + Effect.map(() => { + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("leaves an existing _current_branch file untouched", () => { + const workdir = makeWorkdir(); + const branchesDir = join(workdir, "supabase", ".branches"); + mkdirSync(branchesDir, { recursive: true }); + writeFileSync(join(branchesDir, "_current_branch"), "feature-x"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacyStartInitCurrentBranch(fs, path, workdir); + const content = yield* fs.readFileString(join(branchesDir, "_current_branch")); + expect(content).toBe("feature-x"); + }).pipe( + Effect.provide(BunServices.layer), + Effect.map(() => { + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/docker-create-args.ts b/apps/cli/src/legacy/commands/start/lib/docker-create-args.ts new file mode 100644 index 0000000000..28cf3bfda7 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/docker-create-args.ts @@ -0,0 +1,466 @@ +/** + * Assembles `docker create [cmd...]` argv for the per-service + * containers Go's `supabase start` creates directly against the Docker Engine + * SDK (`utils.DockerStart`, `apps/cli-go/internal/utils/docker.go:363-440`). + * + * Go never shells out to `docker` — it builds `container.Config` / + * `container.HostConfig` / `network.NetworkingConfig` structs and calls + * `Docker.ContainerCreate` over the Engine API. This module is the CLI-shell + * translation of that same struct surface into `docker create` CLI flags, so + * every field here is documented against the exact Go struct field (and, where + * useful, the exact call site) it reproduces. + * + * Unlike the Engine API, `docker create`'s argv has no inherent "field order" + * — Docker parses flags independently of position. The order emitted by + * {@link legacyBuildStartContainerCreateArgs} below is therefore this module's + * own fixed, deterministic convention (grouped: identity → env → volumes → + * ports → healthcheck → restart/security → network → labels → + * entrypoint/image/cmd), chosen for readability and stable unit-test + * snapshots — it carries no Go parity obligation, unlike every flag-to-field + * mapping it emits. + * + * Surveyed call sites (every `container.Config{`/`network.NetworkingConfig{` + * block across the Go source, per the task's "read every one" instruction): + * - `apps/cli-go/internal/start/start.go:350-394` Logflare/analytics + * - `apps/cli-go/internal/start/start.go:396-484` Vector + * - `apps/cli-go/internal/start/start.go:486-627` Kong + * - `apps/cli-go/internal/start/start.go:629-851` GoTrue + * - `apps/cli-go/internal/start/start.go:853-901` Mailpit/Inbucket + * - `apps/cli-go/internal/start/start.go:903-958` Realtime + * - `apps/cli-go/internal/start/start.go:960-992` PostgREST/Rest + * - `apps/cli-go/internal/start/start.go:994-1057` Storage + * - `apps/cli-go/internal/start/start.go:1059-1099` Storage ImgProxy + * - `apps/cli-go/internal/start/start.go:1110-1146` pg-meta + * - `apps/cli-go/internal/start/start.go:1148-1191` Studio + * - `apps/cli-go/internal/start/start.go:1193-1268` Pooler + * - `apps/cli-go/internal/db/start/start.go:63-131` Postgres (the db container) + * + * Fields deliberately NOT modelled, because none of the 13 call sites above + * set them on `container.Config`/`container.HostConfig` (verified by grepping + * every block for these field names): `User`, `WorkingDir`, `Tty`, `CapAdd`, + * `Ulimits`, `ShmSize`. Add them here — following the same "optional field, + * flag omitted unless present" shape as every other field below — the day a + * call site actually needs one; there is no value in modelling Docker surface + * this builder never has to reproduce. + * + * One field has no Go struct equivalent at all: {@link LegacyStartContainerSpec.secretFiles}. + * It exists purely because this module's own "shell out to `docker create`" + * architecture (unlike Go's direct Engine API calls) has an argv-exposure + * problem `container.Config`/`container.HostConfig` never had — see that + * field's doc comment, and `container-lifecycle.ts`'s `legacyStartContainer`, + * for the mitigation. + */ + +import { + legacyBindMountSpecSource, + legacyIsBindMountSource, +} from "../../../shared/legacy-docker-bind-classify.ts"; + +/** + * `container.HealthConfig` (`docker/docker/api/types/container`). Not + * exported on its own — callers reference it structurally through + * {@link LegacyStartContainerSpec.healthcheck}; nothing outside this module + * needs to name the shape directly. + */ +interface LegacyStartHealthcheckSpec { + /** + * Go's `HealthConfig.Test` exec form (`["CMD", ...args]`) or shell form + * (`["CMD-SHELL", script]`). See {@link legacyBuildHealthCmdArg} for exactly + * how each form becomes the single `--health-cmd` string docker CLI expects. + */ + readonly test: ReadonlyArray; + /** `HealthConfig.Interval`, already-whole seconds → `--health-interval s`. */ + readonly intervalSeconds?: number; + /** `HealthConfig.Timeout` → `--health-timeout s`. */ + readonly timeoutSeconds?: number; + /** `HealthConfig.Retries` → `--health-retries `. */ + readonly retries?: number; + /** + * `HealthConfig.StartPeriod` → `--health-start-period s`. Only Logflare + * and Mailpit set this (`start.go:371`, `start.go:882`); every other + * healthcheck leaves Go's zero value, which docker treats as "unset" — so + * this stays optional and the flag is omitted, not emitted as `0s`. + */ + readonly startPeriodSeconds?: number; +} + +/** + * `-p :[/]` — one + * `container.HostConfig.PortBindings` entry. Not exported; referenced + * structurally through {@link LegacyStartContainerSpec.ports}. + */ +interface LegacyStartPortBindingSpec { + readonly hostPort: string; + readonly containerPort: string; + /** Defaults to `"tcp"` (omitted from the flag) — no call site uses `"udp"` today. */ + readonly protocol?: "tcp" | "udp"; +} + +/** + * `--expose [/]` — one `container.Config.ExposedPorts` + * entry with no matching `PortBindings` entry (kept separate from + * {@link LegacyStartPortBindingSpec} on purpose, see the doc comment on + * {@link LegacyStartContainerSpec.exposedPorts}). Not exported; referenced + * structurally through {@link LegacyStartContainerSpec.exposedPorts}. + */ +interface LegacyStartExposedPortSpec { + readonly containerPort: string; + readonly protocol?: "tcp" | "udp"; +} + +/** + * One entry a caller must stage as a HOST temp file and bind-mount read-only + * into the container — see {@link LegacyStartContainerSpec.secretFiles}'s doc + * comment for the full contract. Not exported on its own — callers reference + * it structurally through that field; nothing outside this module needs to + * name the shape directly. + */ +interface LegacyStartSecretFileSpec { + /** The fixed path INSIDE the container the caller's generated bind mount targets. */ + readonly containerPath: string; + /** The secret content to write to a HOST temp file — never emitted into argv. */ + readonly content: string; +} + +export interface LegacyStartContainerSpec { + /** `container.Config.Image` (already resolved/pulled — resolution is out of scope here). */ + readonly image: string; + /** The 4th `DockerStart` positional argument — `--name`. */ + readonly containerName: string; + /** + * `container.Config.Hostname`. Only Logflare sets this (`start.go:353`, + * `Hostname: "127.0.0.1"`); every other service leaves it unset and relies + * on Docker's default (the container's own short ID). + */ + readonly hostname?: string; + /** + * `container.Config.Env`, reshaped from Go's `KEY=value` string slice into a + * map. Emitted as the key-only `-e KEY` form — see the doc comment on + * {@link legacyBuildStartContainerCreateArgs} — so secret values (JWT + * secrets, SMTP passwords, API keys — every one of these containers carries + * at least one) never appear in this process's own argv. + */ + readonly env: Readonly>; + /** + * Entrypoint/`Cmd`-script secret content that must land at a specific path + * INSIDE the container without ever appearing in this process's own + * `docker create` argv (`ps aux`/`/proc//cmdline`, CWE-214/522) — the + * entrypoint/`Cmd` analogue of {@link env}'s key-only `-e KEY` protection. + * Kong/Postgres/Supavisor all heredoc or shell-embed secret-bearing content + * (Kong's `kong.yml`/TLS private key, Postgres's pgsodium root key, + * Supavisor's rendered `pooler.exs`) directly into their entrypoint script + * or `Cmd` — safe in Go's Engine-API architecture (never a subprocess's own + * argv) but not in this port's, which shells out to a real `docker create`. + * + * NOT consumed here: {@link legacyBuildStartContainerCreateArgs} stays + * pure/no-I/O and never reads this field. `container-lifecycle.ts`'s + * `legacyStartContainer` is the sole consumer — it writes each entry's + * `content` to a HOST-side temp file (mode `0644` — world-readable, so the + * non-root in-container user reading it (e.g. Kong, Postgres) doesn't hit + * `EACCES` once the bind mount preserves this host mode verbatim; see + * `legacyStageStartSecretFiles`'s doc comment — in a fresh temp + * directory) and appends a `::ro` bind (the + * bind's SOURCE is a generated temp-file path, never the secret itself — + * safe in argv) to {@link binds} BEFORE this builder ever sees the spec, + * then removes the temp file/directory once the container is created and + * started. Generic by design — any future service's spec can set this, not + * just the three call sites that need it today. + */ + readonly secretFiles?: ReadonlyArray; + /** + * `container.Config.Entrypoint`'s first element. Docker CLI's `--entrypoint` + * only accepts a single executable/script name (unlike the Engine API field, + * which is a full argv array) — the remaining Go `Entrypoint` elements are + * reproduced via {@link cmd} instead, exactly like the existing `docker run` + * precedent (`legacy-docker-run.service.ts`'s `entrypoint`/`cmd` split). + * E.g. Go's `Entrypoint: ["sh", "-c", script]` (Logflare/Vector/Kong/db) + * becomes `entrypoint: "sh", cmd: ["-c", script]`. + */ + readonly entrypoint?: string; + /** + * Trailing argv tokens placed after the image. Always emitted — unlike the + * `docker run` precedent's comment (which only ever pairs `cmd` with + * `entrypoint`), Pooler (`start.go:1234-1237`) sets `container.Config.Cmd` + * with NO `Entrypoint` at all: it overrides the pooler image's default `CMD` + * while keeping its own `ENTRYPOINT`. Docker CLI's trailing-tokens-after-image + * convention already covers both cases identically (args to `--entrypoint` + * when set, or a `CMD` override of the image default when not), so `cmd` is + * independent of {@link entrypoint} here. + */ + readonly cmd?: ReadonlyArray; + /** + * `container.HostConfig.Binds` — `"source:target[:mode]"` bind-mount strings + * or `"volumeName:target"` named-volume strings (Go's `loader.ParseVolume` + * classification, see {@link legacyIsBindMountSource}). Named-volume + * creation itself (`Docker.VolumeCreate`, `docker.go:407-415`) is a + * higher-level orchestration concern, not this pure argv builder's job. + */ + readonly binds: ReadonlyArray; + /** `container.HostConfig.VolumesFrom` — ImgProxy only (`start.go:1084`, mounts Storage's volumes). */ + readonly volumesFrom?: ReadonlyArray; + /** + * `container.HostConfig.Tmpfs` (`map[mountPath]mountOptions`) — only the + * Postgres container sets this, and only on PG ≤ 14 + * (`apps/cli-go/internal/db/start/start.go:127-129`: + * `map[string]string{"/docker-entrypoint-initdb.d": ""}`). An empty options + * string means "no extra tmpfs mount options" (`--tmpfs ` with no + * `:options` suffix); a non-empty value is joined as `:`. + */ + readonly tmpfs?: Readonly>; + /** + * `container.HostConfig.PortBindings` — ports published to the host via + * `-p`. Distinct from {@link exposedPorts}: publishing a port with `-p` + * already implies exposing it (docker CLI infers `ExposedPorts` from `-p` + * the same way Go's own `container.Config.ExposedPorts` happens to overlap + * with `PortBindings` in the Logflare example, `start.go:373` vs `:377`). + */ + readonly ports?: ReadonlyArray; + /** + * `container.Config.ExposedPorts` entries that have NO matching + * `PortBindings` entry — i.e. ports declared reachable on the Docker network + * but never published to the host. This is a real, recurring pattern, not a + * one-off: GoTrue (`start.go:825`, port 9999) and Realtime (`start.go:930`, + * port 4000) expose a port with zero `PortBindings`; Kong exposes 3 ports + * (`start.go:602-606`) but only ever publishes one of them depending on TLS; + * Pooler exposes 3 ports (`start.go:1238-1242`) but only ever publishes the + * one matching its configured pool mode. A single `ports` field (CLI `-p`) + * cannot express "exposed but not published", so it is kept as its own field + * rather than folded into {@link ports} with an optional host side. + */ + readonly exposedPorts?: ReadonlyArray; + /** `container.Config.Healthcheck`. Omitted entirely for Kong and PostgREST — see `start.go:975` ("PostgREST does not expose a shell for health check") — so no `--health-*` flags are emitted for those services. */ + readonly healthcheck?: LegacyStartHealthcheckSpec; + /** + * `container.HostConfig.RestartPolicy.Name`. Every one of the 13 surveyed + * call sites uses `container.RestartPolicyUnlessStopped` (verified — grep + * `RestartPolicy` across `start.go`/`db/start/start.go` returns only + * `RestartPolicyUnlessStopped`), but the full docker CLI `--restart` enum is + * supported for completeness/future callers, per the task brief. + */ + readonly restartPolicy?: "unless-stopped" | "no" | "always" | "on-failure"; + /** + * `container.HostConfig.SecurityOpt`. Only Vector sets this + * (`start.go:441`, `"label:disable"`, when mounting a non-root Docker + * socket) — cleared globally under Bitbucket Pipelines, see + * {@link legacyApplyBitbucketStartContainerFilter}. + */ + readonly securityOpt?: ReadonlyArray; + /** + * `container.HostConfig.ExtraHosts`. Populated by `DockerStart` itself from + * the platform-specific `extraHosts` package var (`docker_linux.go`, + * `docker_darwin.go`, `docker_windows.go`), not by any individual + * `container.Config` literal — every container gets the same extra hosts. + * That orchestration (merging in the platform default) is a caller concern; + * this builder just emits whatever is passed here. + */ + readonly extraHosts?: ReadonlyArray; + /** + * `container.HostConfig.NetworkMode`, resolved by `DockerStart` + * (`docker.go:379-383`) to either the `--network-id` override or + * `utils.NetId` — never set per-container in any surveyed call site. + */ + readonly networkId: string; + /** + * `network.NetworkingConfig.EndpointsConfig[NetId].Aliases` — the per-service + * alias array (e.g. `utils.RealtimeAliases = ["realtime", tenantId]`, + * `apps/cli-go/internal/utils/config.go:40`). The container's OWN name is + * already DNS-resolvable on a user-defined network without any alias; this + * field only adds the short/extra names other containers' env vars and + * templates reference by. + */ + readonly networkAliases?: ReadonlyArray; + /** + * `container.Config.Labels`, merged in by `DockerStart` itself + * (`docker.go:372-376`: `CliProjectLabel`/`composeProjectLabel`, both the + * sanitized project id) rather than set per-service — this builder emits + * whatever map is passed here, unconditionally. + */ + readonly labels: Readonly>; +} + +function formatDockerDurationSeconds(seconds: number): string { + return `${seconds}s`; +} + +/** + * Quotes a single argv-style argument for safe embedding in a POSIX shell + * command line: wraps it in single quotes, escaping any embedded single quote + * as `'\''` (close quote, escaped literal quote, reopen quote) — the standard + * POSIX-portable technique (equivalent to Python's `shlex.quote`). Arguments + * containing only characters that never need quoting are returned unchanged + * for readability. + */ +function legacyShellQuoteArg(arg: string): string { + if (arg.length > 0 && /^[A-Za-z0-9_\-./:@%,+=]+$/.test(arg)) return arg; + return `'${arg.replaceAll("'", "'\\''")}'`; +} + +/** + * Converts Go's `HealthConfig.Test` into the single string `docker create + * --health-cmd` expects. + * + * Docker CLI's `--health-cmd` flag has no exec-form equivalent: whatever + * string it is given always becomes `HealthConfig.Test = ["CMD-SHELL", + * value]` on the resulting container (there is no CLI flag that produces the + * exec-form `["CMD", ...]` Go sometimes uses directly over the Engine API — + * only a Dockerfile `HEALTHCHECK` instruction or the raw API can do that). So: + * + * - `["CMD-SHELL", script]` (pg-meta/Studio, `start.go:1125`/`:1166`) — `script` + * is already the exact string Go's own `CMD-SHELL` form would run verbatim + * via the container's `/bin/sh -c`; it is forwarded completely unmodified. + * No quoting is applied on this side because this string travels as a + * single argv element to the spawned `docker` process (no host-side shell + * parses it) — only Docker's own eventual in-container `/bin/sh -c + * ` interprets it, exactly reproducing Go's `Test` value untouched. + * - `["CMD", ...args]` (exec form — e.g. Logflare's `["CMD", "curl", "-sSfL", + * "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"]`, + * `start.go:365-366`) — since `--health-cmd` always produces a `CMD-SHELL` + * test, each `args` element is POSIX-shell-quoted individually (via + * {@link legacyShellQuoteArg}) and joined with spaces, so that when Docker + * later runs `/bin/sh -c ""` inside the container, the shell + * re-splits it back into the exact same argv the exec form specified — + * including an argument containing spaces or embedded quotes, which none of + * the current 14 services happen to need but which this conversion must + * still get right. + */ +export function legacyBuildHealthCmdArg(test: ReadonlyArray): string { + const [mode, ...rest] = test; + if (mode === "CMD-SHELL") return rest[0] ?? ""; + return rest.map(legacyShellQuoteArg).join(" "); +} + +function formatPortBindingFlag(port: LegacyStartPortBindingSpec): string { + const suffix = port.protocol === "udp" ? "/udp" : ""; + return `${port.hostPort}:${port.containerPort}${suffix}`; +} + +function formatExposedPortFlag(port: LegacyStartExposedPortSpec): string { + const suffix = port.protocol === "udp" ? "/udp" : ""; + return `${port.containerPort}${suffix}`; +} + +function buildHealthcheckArgs(healthcheck: LegacyStartHealthcheckSpec): ReadonlyArray { + const args: Array = ["--health-cmd", legacyBuildHealthCmdArg(healthcheck.test)]; + if (healthcheck.intervalSeconds !== undefined) { + args.push("--health-interval", formatDockerDurationSeconds(healthcheck.intervalSeconds)); + } + if (healthcheck.timeoutSeconds !== undefined) { + args.push("--health-timeout", formatDockerDurationSeconds(healthcheck.timeoutSeconds)); + } + if (healthcheck.retries !== undefined) { + args.push("--health-retries", String(healthcheck.retries)); + } + if (healthcheck.startPeriodSeconds !== undefined) { + args.push("--health-start-period", formatDockerDurationSeconds(healthcheck.startPeriodSeconds)); + } + return args; +} + +/** + * Docker/Podman CLI env vars that configure the CLIENT itself — which daemon + * it connects to — rather than a value for the container being created. A + * container spec's own `env` can legitimately need to set one of these (e.g. + * Vector's `DOCKER_HOST=http://host.docker.internal:`, set by + * `legacyResolveVectorDockerSocketPlan` for a `tcp`/`npipe` daemon host so + * Vector can reach the real daemon from inside its own container), but that + * value must never be inherited by the spawned `docker`/`podman create` + * PROCESS's own environment: doing so would hijack which daemon that process + * itself talks to before the container even exists (see + * `legacyDockerCreateContainer`, `container-lifecycle.ts`, which filters these + * keys out of the env it hands to the spawned process for exactly this + * reason). These are not secrets, so unlike the rest of `spec.env` they are + * safe to emit inline as `-e KEY=value` instead of the key-only form. + */ +const DOCKER_CLIENT_ENV_KEYS: ReadonlySet = new Set([ + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + "DOCKER_CONTEXT", + "DOCKER_API_VERSION", +]); + +/** Whether `key` configures the Docker/Podman CLI client itself — see {@link DOCKER_CLIENT_ENV_KEYS}. */ +export function legacyIsDockerClientEnvKey(key: string): boolean { + return DOCKER_CLIENT_ENV_KEYS.has(key); +} + +/** + * Assemble the `docker create` argv for one `supabase start` service + * container. Pure (no Effect) so every flag mapping is unit-testable in + * isolation, matching the `buildLegacyDockerArgs` (`docker run`) precedent. + * + * `"create"` is argv[0] — the caller (`legacy-container-cli.ts`'s + * `spawnContainerCli`/`containerCliExitCode`) prepends only the `docker`/ + * `podman` binary itself, exactly like `buildLegacyDockerArgs` returning + * `"run"` as its own argv[0]. + * + * Env is emitted in the key-only `-e KEY` form (never `-e KEY=value`) for the + * same CWE-214/209 reason as `buildLegacyDockerArgs`: these containers' + * env carries JWT secrets, SMTP credentials, API keys, and DB passwords, none + * of which may appear in this process's own argv (`ps aux` / + * `/proc//cmdline`). The spawned `docker create`'s own child environment + * supplies each value — a later caller's responsibility, not this builder's. + * The exception is {@link legacyIsDockerClientEnvKey} keys, which are emitted + * inline as `-e KEY=value` instead — see that function's doc comment. + */ +export function legacyBuildStartContainerCreateArgs( + spec: LegacyStartContainerSpec, +): ReadonlyArray { + return [ + "create", + "--name", + spec.containerName, + ...(spec.hostname === undefined ? [] : ["--hostname", spec.hostname]), + ...Object.entries(spec.env).flatMap(([key, value]) => + legacyIsDockerClientEnvKey(key) ? ["-e", `${key}=${value}`] : ["-e", key], + ), + ...spec.binds.flatMap((bind) => ["-v", bind]), + ...(spec.volumesFrom ?? []).flatMap((source) => ["--volumes-from", source]), + ...Object.entries(spec.tmpfs ?? {}).flatMap(([path, options]) => [ + "--tmpfs", + options.length > 0 ? `${path}:${options}` : path, + ]), + ...(spec.ports ?? []).flatMap((port) => ["-p", formatPortBindingFlag(port)]), + ...(spec.exposedPorts ?? []).flatMap((port) => ["--expose", formatExposedPortFlag(port)]), + ...(spec.healthcheck === undefined ? [] : buildHealthcheckArgs(spec.healthcheck)), + ...(spec.restartPolicy === undefined ? [] : ["--restart", spec.restartPolicy]), + ...(spec.securityOpt ?? []).flatMap((opt) => ["--security-opt", opt]), + ...(spec.extraHosts ?? []).flatMap((host) => ["--add-host", host]), + "--network", + spec.networkId, + ...(spec.networkAliases ?? []).flatMap((alias) => ["--network-alias", alias]), + ...Object.entries(spec.labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]), + // `--entrypoint` must precede the image (it is a `docker create` flag). + ...(spec.entrypoint === undefined ? [] : ["--entrypoint", spec.entrypoint]), + spec.image, + ...(spec.cmd ?? []), + ]; +} + +/** + * Mirror Go's `DockerStart` Bitbucket Pipelines handling + * (`apps/cli-go/internal/utils/docker.go:400-405`): when `BITBUCKET_CLONE_DIR` + * is set, that runner disallows named volumes and `--security-opt`, so Go + * drops named-volume binds and clears `SecurityOpt` before starting any + * container. Mirrors `legacyApplyBitbucketDockerFilter` + * (`legacy-docker-run.args.ts`) for the `docker create` shape — e.g. the + * Postgres container's `_db:/var/lib/postgresql/data` named-volume + * bind is dropped while a bind-mount stays; Vector's non-root Docker-socket + * bind mount (already a bind mount, not a named volume) is unaffected either + * way, but its `SecurityOpt: ["label:disable"]` is cleared. + * + * `volumesFrom` and `tmpfs` are untouched: Go's Bitbucket branch only ever + * reassigns `hostConfig.Binds` and clears `hostConfig.SecurityOpt` + * (`docker.go:401-405`) — it does not touch `VolumesFrom` or `Tmpfs`. + */ +export function legacyApplyBitbucketStartContainerFilter( + spec: LegacyStartContainerSpec, + isBitbucket: boolean, +): LegacyStartContainerSpec { + if (!isBitbucket) return spec; + return { + ...spec, + binds: spec.binds.filter((bind) => legacyIsBindMountSource(legacyBindMountSpecSource(bind))), + securityOpt: [], + }; +} diff --git a/apps/cli/src/legacy/commands/start/lib/docker-create-args.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/docker-create-args.unit.test.ts new file mode 100644 index 0000000000..d4b2e86802 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/docker-create-args.unit.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildStartContainerCreateArgs, + legacyApplyBitbucketStartContainerFilter, + legacyBuildHealthCmdArg, + legacyIsDockerClientEnvKey, + type LegacyStartContainerSpec, +} from "./docker-create-args.ts"; + +// Mirrors the Logflare/analytics container (`start.go:350-394`): the fullest +// worked example in the Go source — Hostname, Entrypoint+Cmd, exec-form +// Healthcheck with StartPeriod, ExposedPorts alongside a matching +// PortBinding, RestartPolicy, and network aliases. +const full: LegacyStartContainerSpec = { + image: "supabase/logflare:1.0.0", + containerName: "supabase_analytics_proj", + hostname: "127.0.0.1", + env: { DB_PASSWORD: "super-secret", DB_HOSTNAME: "supabase_db_proj" }, + entrypoint: "sh", + cmd: ["-c", "./logflare start"], + binds: ["/host/gcloud.json:/opt/app/gcloud.json"], + volumesFrom: ["supabase_storage_proj"], + tmpfs: { "/docker-entrypoint-initdb.d": "" }, + ports: [{ hostPort: "4000", containerPort: "4000" }], + exposedPorts: [{ containerPort: "4000" }], + healthcheck: { + test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }, + restartPolicy: "unless-stopped", + securityOpt: ["label:disable"], + extraHosts: ["host.docker.internal:host-gateway"], + networkId: "supabase_network_proj", + networkAliases: ["analytics"], + labels: { + "com.supabase.cli.project": "proj", + "com.docker.compose.project": "proj", + }, +}; + +describe("legacyBuildStartContainerCreateArgs", () => { + test("assembles full-option argv in the documented fixed order", () => { + expect(legacyBuildStartContainerCreateArgs(full)).toEqual([ + "create", + "--name", + "supabase_analytics_proj", + "--hostname", + "127.0.0.1", + "-e", + "DB_PASSWORD", + "-e", + "DB_HOSTNAME", + "-v", + "/host/gcloud.json:/opt/app/gcloud.json", + "--volumes-from", + "supabase_storage_proj", + "--tmpfs", + "/docker-entrypoint-initdb.d", + "-p", + "4000:4000", + "--expose", + "4000", + "--health-cmd", + "curl -sSfL --head -o /dev/null http://127.0.0.1:4000/health", + "--health-interval", + "10s", + "--health-timeout", + "2s", + "--health-retries", + "3", + "--health-start-period", + "10s", + "--restart", + "unless-stopped", + "--security-opt", + "label:disable", + "--add-host", + "host.docker.internal:host-gateway", + "--network", + "supabase_network_proj", + "--network-alias", + "analytics", + "--label", + "com.supabase.cli.project=proj", + "--label", + "com.docker.compose.project=proj", + "--entrypoint", + "sh", + "supabase/logflare:1.0.0", + "-c", + "./logflare start", + ]); + }); + + test("emits only required flags for the minimal-options case", () => { + const minimal: LegacyStartContainerSpec = { + image: "supabase/postgres-meta:v1", + containerName: "supabase_pg_meta_proj", + env: {}, + binds: [], + networkId: "supabase_network_proj", + labels: {}, + }; + expect(legacyBuildStartContainerCreateArgs(minimal)).toEqual([ + "create", + "--name", + "supabase_pg_meta_proj", + "--network", + "supabase_network_proj", + "supabase/postgres-meta:v1", + ]); + }); + + test("never serializes env values into argv (CWE-214: secrets must not leak to ps)", () => { + const args = legacyBuildStartContainerCreateArgs(full); + expect(args).toContain("DB_PASSWORD"); + expect(args.some((a) => a.includes("super-secret"))).toBe(false); + // Every -e argument is a bare key: no '=' anywhere in an -e value. + const envValues = args.flatMap((a, i) => (args[i - 1] === "-e" ? [a] : [])); + expect(envValues.every((v) => !v.includes("="))).toBe(true); + }); + + test("emits DOCKER_HOST inline as -e KEY=value, not key-only, since it's not a secret (Vector's tcp/npipe daemon host)", () => { + const spec: LegacyStartContainerSpec = { + image: "timberio/vector:0.36.0-alpine", + containerName: "supabase_vector_proj", + env: { DOCKER_HOST: "http://host.docker.internal:2375", API_KEY: "super-secret" }, + binds: [], + networkId: "supabase_network_proj", + labels: {}, + }; + const args = legacyBuildStartContainerCreateArgs(spec); + expect(args).toContain("-e"); + const dockerHostIndex = args.indexOf("DOCKER_HOST=http://host.docker.internal:2375"); + expect(dockerHostIndex).toBeGreaterThan(-1); + expect(args[dockerHostIndex - 1]).toBe("-e"); + // The genuine secret alongside it must still stay key-only. + expect(args).toContain("API_KEY"); + expect(args.some((a) => a.includes("super-secret"))).toBe(false); + }); + + test("legacyIsDockerClientEnvKey recognizes Docker/Podman client env vars and nothing else", () => { + expect(legacyIsDockerClientEnvKey("DOCKER_HOST")).toBe(true); + expect(legacyIsDockerClientEnvKey("DOCKER_TLS_VERIFY")).toBe(true); + expect(legacyIsDockerClientEnvKey("DOCKER_CERT_PATH")).toBe(true); + expect(legacyIsDockerClientEnvKey("DOCKER_CONTEXT")).toBe(true); + expect(legacyIsDockerClientEnvKey("DOCKER_API_VERSION")).toBe(true); + expect(legacyIsDockerClientEnvKey("DB_PASSWORD")).toBe(false); + }); + + test("omits the protocol suffix for tcp ports and adds /udp when specified", () => { + const spec: LegacyStartContainerSpec = { + image: "img", + containerName: "c", + env: {}, + binds: [], + networkId: "net", + labels: {}, + ports: [ + { hostPort: "53", containerPort: "53", protocol: "udp" }, + { hostPort: "80", containerPort: "80" }, + { hostPort: "81", containerPort: "81", protocol: "tcp" }, + ], + exposedPorts: [{ containerPort: "9999" }, { containerPort: "9998", protocol: "udp" }], + }; + const args = legacyBuildStartContainerCreateArgs(spec); + expect(args).toEqual( + expect.arrayContaining([ + "-p", + "53:53/udp", + "-p", + "80:80", + "-p", + "81:81", + "--expose", + "9999", + "--expose", + "9998/udp", + ]), + ); + }); + + test("emits --tmpfs with :options only when options are non-empty", () => { + const spec: LegacyStartContainerSpec = { + image: "img", + containerName: "c", + env: {}, + binds: [], + networkId: "net", + labels: {}, + tmpfs: { "/tmp/bare": "", "/tmp/opts": "rw,size=100m" }, + }; + const args = legacyBuildStartContainerCreateArgs(spec); + expect(args).toEqual( + expect.arrayContaining(["--tmpfs", "/tmp/bare", "--tmpfs", "/tmp/opts:rw,size=100m"]), + ); + }); + + test("emits cmd tokens after the image even when entrypoint is absent (Pooler: start.go:1234-1237)", () => { + const spec: LegacyStartContainerSpec = { + image: "supabase/supavisor:2.0.0", + containerName: "supabase_pooler_proj", + env: {}, + binds: [], + networkId: "net", + labels: {}, + cmd: ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/server"], + }; + const args = legacyBuildStartContainerCreateArgs(spec); + expect(args).not.toContain("--entrypoint"); + const imageIdx = args.indexOf("supabase/supavisor:2.0.0"); + expect(args.slice(imageIdx)).toEqual([ + "supabase/supavisor:2.0.0", + "/bin/sh", + "-c", + "/app/bin/migrate && /app/bin/server", + ]); + }); + + test("omits health flags entirely when healthcheck is absent (Kong/PostgREST: start.go:975)", () => { + const spec: LegacyStartContainerSpec = { + image: "kong:3", + containerName: "supabase_kong_proj", + env: {}, + binds: [], + networkId: "net", + labels: {}, + }; + const args = legacyBuildStartContainerCreateArgs(spec); + expect(args.some((a) => a.startsWith("--health"))).toBe(false); + }); + + test("never reads spec.secretFiles — the pure builder emits nothing from it (container-lifecycle.ts alone stages it into a real bind, CWE-214/522)", () => { + const spec: LegacyStartContainerSpec = { + image: "kong:3", + containerName: "supabase_kong_proj", + env: {}, + binds: ["/host/other.txt:/etc/other.txt"], + networkId: "net", + labels: {}, + secretFiles: [{ containerPath: "/etc/secret.yml", content: "top-secret-content" }], + }; + const args = legacyBuildStartContainerCreateArgs(spec); + expect(args.some((a) => a.includes("top-secret-content"))).toBe(false); + expect(args.some((a) => a.includes("/etc/secret.yml"))).toBe(false); + // Only the spec's own `binds` entries are ever emitted — secretFiles contributes nothing. + expect(args.filter((a) => a === "-v")).toHaveLength(1); + }); +}); + +describe("legacyBuildHealthCmdArg", () => { + test("forwards CMD-SHELL scripts verbatim, with no quoting applied", () => { + const script = `node --eval="fetch('http://127.0.0.1:8080/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`; + expect(legacyBuildHealthCmdArg(["CMD-SHELL", script])).toBe(script); + }); + + test("shell-quotes each exec-form argument and joins with spaces", () => { + expect( + legacyBuildHealthCmdArg([ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/health", + ]), + ).toBe("curl -sSfL --head -o /dev/null http://127.0.0.1:4000/health"); + }); + + test("quotes an exec-form argument containing spaces so the container shell re-splits it back to one token", () => { + expect(legacyBuildHealthCmdArg(["CMD", "echo", "hello world"])).toBe("echo 'hello world'"); + }); + + test("escapes an embedded single quote using the '\\'' POSIX technique", () => { + expect(legacyBuildHealthCmdArg(["CMD", "echo", "it's here"])).toBe("echo 'it'\\''s here'"); + }); +}); + +describe("legacyApplyBitbucketStartContainerFilter", () => { + const dbLike: LegacyStartContainerSpec = { + image: "supabase/postgres:15", + containerName: "supabase_db_proj", + env: {}, + binds: ["supabase_db_proj:/var/lib/postgresql/data", "/repo/backup.sql:/etc/backup.sql:ro"], + networkId: "net", + labels: {}, + securityOpt: ["label:disable"], + tmpfs: { "/docker-entrypoint-initdb.d": "" }, + volumesFrom: ["supabase_storage_proj"], + }; + + test("passes the spec through unchanged outside Bitbucket", () => { + expect(legacyApplyBitbucketStartContainerFilter(dbLike, false)).toBe(dbLike); + }); + + test("drops named-volume binds and clears security-opt under Bitbucket, leaving tmpfs/volumesFrom untouched", () => { + const filtered = legacyApplyBitbucketStartContainerFilter(dbLike, true); + expect(filtered.binds).toEqual(["/repo/backup.sql:/etc/backup.sql:ro"]); + expect(filtered.securityOpt).toEqual([]); + expect(filtered.tmpfs).toEqual({ "/docker-entrypoint-initdb.d": "" }); + expect(filtered.volumesFrom).toEqual(["supabase_storage_proj"]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/health-check.ts b/apps/cli/src/legacy/commands/start/lib/health-check.ts new file mode 100644 index 0000000000..f21e6b9693 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/health-check.ts @@ -0,0 +1,296 @@ +/** + * Port of Go's `WaitForHealthyService`/`IsServiceReady` + * (`apps/cli-go/internal/db/start/start.go:192-231`, + * `apps/cli-go/internal/status/status.go:147-168`): a single shared probe + * across every still-unhealthy started container, on a 1-second constant + * backoff, for up to `timeoutSeconds` retries (Go's default `serviceTimeout = + * 30 * time.Second`) — NOT independent per-container timers. Each tick probes + * every still-unhealthy container, narrows the "still watching" set to just + * the ones that failed this round (a healthy container stops being probed), + * and only the final timeout's failures surface to the caller. + */ + +import { Data, Effect, Schedule, Stream } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyInspectContainerState } from "../../../shared/legacy-docker-lifecycle.ts"; +import { legacyKongAuthHeaders } from "../../../shared/legacy-kong-auth.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** Go's `serviceTimeout` (`apps/cli-go/internal/start/start.go:161`). */ +const LEGACY_HEALTH_CHECK_TIMEOUT_SECONDS = 30; + +/** + * Go's `healthProbeTimeout` (`apps/cli-go/internal/status/status.go:209`): caps + * a single HTTP readiness probe so a hung response cannot stall the + * surrounding retry loop. + */ +const LEGACY_HTTP_PROBE_TIMEOUT_SECONDS = 10; + +/** `apps/cli-go/internal/status/status.go:161` — PostgREST does not support native Docker healthchecks. */ +const LEGACY_POSTGREST_READY_PATH = "/rest-admin/v1/ready"; + +/** + * `apps/cli-go/internal/status/status.go:163-166` — Edge Runtime bypasses its + * native Docker healthcheck too ("native health check logs too much + * hyper::Error(IncompleteMessage)"), through the exact same + * {@link legacyCheckHttpReady}/Kong-gateway path as PostgREST, just its own + * path and container id. Go's `checkHTTPHead` even shares one lazily-built + * `healthClient` across both call sites (`status.go:202-219`) — the closest + * equivalent here is {@link LegacyHealthCheckPostgrestGateway} being reused + * as-is (name notwithstanding — its shape is generic, not + * PostgREST-specific) for both {@link LegacyWaitForHealthyServicesOptions.postgrest} + * and {@link LegacyWaitForHealthyServicesOptions.edgeRuntime}. + */ +const LEGACY_EDGE_RUNTIME_READY_PATH = "/functions/v1/_internal/health"; + +/** Identifies a single container's readiness failure this round. */ +export interface LegacyHealthCheckFailure { + readonly containerId: string; + readonly reason: string; +} + +/** Internal-only: one probe round's failures, narrowing which containers are still watched next round. */ +class LegacyHealthCheckProbeError extends Data.TaggedError("LegacyHealthCheckProbeError")<{ + readonly failures: ReadonlyArray; +}> {} + +/** + * The retry loop's final, and only surfaced, failure — mirrors Go returning + * `errors.Join(errHealth...)` from the last failed `probe()` call once + * `backoff.Retry` gives up (`start.go:210-214`). + */ +export class LegacyHealthCheckTimeoutError extends Data.TaggedError( + "LegacyHealthCheckTimeoutError", +)<{ + readonly message: string; + readonly unhealthy: ReadonlyArray; +}> {} + +/** + * PostgREST's local Kong gateway coordinates, mirroring Go's + * `fetcher.NewServiceGateway(utils.Config.Api.ExternalUrl, + * utils.Config.Auth.SecretKey.Value, ...)` (`status.go:213-218`). TLS/CA trust + * for a local https gateway is the caller's responsibility when composing the + * `HttpClient.HttpClient` layer this module requires — same split as + * `legacy-storage-gateway.ts`/`legacyStorageGatewayFetch`. + * + * `start.command.ts` composes the `HttpClient.HttpClient` this module + * requires via `legacyHttpClientLayer` (itself `FetchHttpClient`-backed, and + * on its own CA-unaware; see that layer's own header) — the same layer + * `db reset`/`seed buckets` compose for the equivalent gateway calls. + * `start.handler.ts` layers a CA-trusting override on top of that: when + * `api.tls.enabled`, `apiExternalUrl` is `https://` against Kong's + * self-signed local cert (`KONG_LOCAL_CA_CERT`, or a validated + * `api.tls.cert_path` override), so before calling + * {@link legacyWaitForHealthyServices} it resolves that same CA via + * `legacy-storage-credentials.ts`'s `legacyResolveStorageCredentials` (the + * mechanism `seed buckets`/`storage`/`db reset` already use) and, when a + * local CA resolves, overrides `FetchHttpClient.Fetch` with + * `legacyStorageGatewayFetch` around the health-check call via + * `Effect.provideService`. That override only takes effect against a + * `FetchHttpClient`-backed `HttpClient.HttpClient` — exactly what + * `legacyHttpClientLayer` provides — so a stack started with + * `[api.tls] enabled = true` now gets a `legacyCheckHttpReady` probe that + * trusts the local Kong CA instead of exhausting `legacyWaitForHealthyServices`'s + * full 30s budget on a TLS verification failure. + */ +export interface LegacyHealthCheckPostgrestGateway { + readonly containerId: string; + readonly apiExternalUrl: string; + readonly secretKey: string; +} + +export interface LegacyWaitForHealthyServicesOptions { + readonly timeoutSeconds?: number; + readonly postgrest?: LegacyHealthCheckPostgrestGateway; + /** See {@link LEGACY_EDGE_RUNTIME_READY_PATH}'s doc comment for why this reuses the same gateway shape as {@link postgrest}. */ + readonly edgeRuntime?: LegacyHealthCheckPostgrestGateway; +} + +/** + * Go's `assertContainerHealthy` (`status.go:147-156`), reused verbatim via + * {@link legacyInspectContainerState} — the same primitive `status.handler.ts` + * already uses for the exact same not-running/not-ready gating. + */ +function legacyCheckContainerReady( + spawner: Spawner, + containerId: string, +): Effect.Effect { + return legacyInspectContainerState(spawner, containerId).pipe( + Effect.mapError((cause) => cause.message), + Effect.flatMap((state) => { + 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}`); + } + return Effect.void; + }), + ); +} + +/** + * Go's `checkHTTPHead` (`status.go:211-229`): an HTTP HEAD through the local + * Kong gateway, expecting exactly 200. Bypasses the Docker healthcheck + * entirely — PostgREST "does not support native health checks" + * (`status.go:159-161`). + */ +function legacyCheckHttpReady( + gateway: LegacyHealthCheckPostgrestGateway, + path: string, +): Effect.Effect { + return Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const request = HttpClientRequest.head(`${gateway.apiExternalUrl}${path}`).pipe( + HttpClientRequest.setHeaders(legacyKongAuthHeaders(gateway.secretKey)), + ); + const response = yield* httpClient.execute(request).pipe( + Effect.timeout(`${LEGACY_HTTP_PROBE_TIMEOUT_SECONDS} seconds`), + Effect.mapError((cause) => String(cause)), + ); + if (response.status !== 200) { + return yield* Effect.fail(`unexpected status ${response.status}`); + } + }); +} + +/** + * Go's `DockerStreamLogsOnce` (`apps/cli-go/internal/utils/docker.go:593-606`) + * via `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. + */ +function legacyStreamContainerLogsOnce(spawner: Spawner, containerId: string): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawnContainerCli(spawner, ["logs", containerId], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + yield* Effect.all( + [ + Stream.runForEach(handle.stdout, (chunk) => + Effect.sync(() => { + globalThis.process.stderr.write(chunk); + }), + ), + Stream.runForEach(handle.stderr, (chunk) => + Effect.sync(() => { + globalThis.process.stderr.write(chunk); + }), + ), + ], + { concurrency: "unbounded" }, + ); + yield* handle.exitCode; + }), + ).pipe(Effect.orElseSucceed(() => undefined)); +} + +/** Go's `fmt.Fprintln(os.Stderr, containerId, "container logs:")` (`start.go:218`) + the log dump itself. */ +function legacyDumpContainerLogs(spawner: Spawner, containerId: string): Effect.Effect { + return Effect.gen(function* () { + yield* Effect.sync(() => { + globalThis.process.stderr.write(`${containerId} container logs:\n`); + }); + yield* legacyStreamContainerLogsOnce(spawner, containerId); + }); +} + +/** + * Waits for every container in `containerIds` to become ready, mirroring + * Go's `WaitForHealthyService(ctx, timeout, started...)`. Resolves once all + * are ready; fails with {@link LegacyHealthCheckTimeoutError} (carrying every + * still-unhealthy container's last-seen reason) once the retry budget is + * exhausted. The caller (`start.handler.ts`) decides whether + * `--ignore-health-check` turns that failure into a warning instead of a hard + * exit — this module only implements the polling contract. + */ +export function legacyWaitForHealthyServices( + spawner: Spawner, + containerIds: ReadonlyArray, + opts: LegacyWaitForHealthyServicesOptions = {}, +): Effect.Effect { + const timeoutSeconds = opts.timeoutSeconds ?? LEGACY_HEALTH_CHECK_TIMEOUT_SECONDS; + const postgrest = opts.postgrest; + const edgeRuntime = opts.edgeRuntime; + + const checkOne = (containerId: string): Effect.Effect => { + if (postgrest !== undefined && containerId === postgrest.containerId) { + return legacyCheckHttpReady(postgrest, LEGACY_POSTGREST_READY_PATH); + } + if (edgeRuntime !== undefined && containerId === edgeRuntime.containerId) { + return legacyCheckHttpReady(edgeRuntime, LEGACY_EDGE_RUNTIME_READY_PATH); + } + return legacyCheckContainerReady(spawner, containerId); + }; + + return Effect.gen(function* () { + let stillWatching = containerIds; + + // Mirrors Go's closure-captured `started` slice + // (`db/start/start.go:200-212`): each round narrows `stillWatching` to + // just the containers that failed, so a container that becomes healthy + // mid-run stops being probed on later rounds. + const probe: Effect.Effect = + Effect.gen(function* () { + const outcomes = yield* Effect.forEach( + stillWatching, + (containerId) => + checkOne(containerId).pipe( + Effect.match({ + onFailure: (reason): LegacyHealthCheckFailure | undefined => ({ + containerId, + reason, + }), + onSuccess: (): LegacyHealthCheckFailure | undefined => undefined, + }), + ), + { concurrency: "unbounded" }, + ); + const failures = outcomes.filter( + (outcome): outcome is LegacyHealthCheckFailure => outcome !== undefined, + ); + stillWatching = failures.map((failure) => failure.containerId); + if (failures.length > 0) { + return yield* Effect.fail(new LegacyHealthCheckProbeError({ failures })); + } + }); + + // `backoff.WithMaxRetries(backoff.NewConstantBackOff(time.Second), + // uint64(timeout.Seconds()))` (`db/start/start.go:192-198`): a 1-second + // constant delay, capped at `timeoutSeconds` retries after the initial + // attempt (~`timeoutSeconds` further seconds elapsed on total failure). + const schedule = Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(timeoutSeconds)]); + + yield* probe.pipe( + Effect.retry(schedule), + Effect.catch((probeError) => + Effect.gen(function* () { + // Go skips this dump on context cancellation (`start.go:215`, + // `!errors.Is(err, context.Canceled)`) — an interrupted fiber never + // reaches this `Effect.catch` handler at all, so no separate check + // is needed here. + yield* Effect.forEach(probeError.failures, (failure) => + legacyDumpContainerLogs(spawner, failure.containerId), + ); + return yield* Effect.fail( + new LegacyHealthCheckTimeoutError({ + message: probeError.failures + .map((failure) => `${failure.containerId}: ${failure.reason}`) + .join("\n"), + unhealthy: probeError.failures, + }), + ); + }), + ), + ); + }); +} diff --git a/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts new file mode 100644 index 0000000000..ed0d287421 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as TestClock from "effect/testing/TestClock"; + +import { + LegacyHealthCheckTimeoutError, + legacyWaitForHealthyServices, + type LegacyHealthCheckPostgrestGateway, +} from "./health-check.ts"; + +/** + * A spawner that answers `docker container inspect`/`docker logs` calls. + * `inspectResponse` is called once per `(containerId, callIndex)` pair — the + * `callIndex` (0-based, per container) lets a test script a container's + * health across successive polling rounds. `docker logs` calls (the + * timeout-path debug dump) always succeed with empty output. + */ +function mockHealthSpawner(inspectResponse: (containerId: string, callIndex: number) => string) { + const counts = new Map(); + const encoder = new TextEncoder(); + const spawned: Array> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + + let stdout = ""; + if (args[0] === "container" && args[1] === "inspect") { + const containerId = args[2] ?? ""; + const callIndex = counts.get(containerId) ?? 0; + counts.set(containerId, callIndex + 1); + stdout = inspectResponse(containerId, callIndex); + } + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(stdout.length > 0 ? [encoder.encode(stdout)] : []), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +const runningHealthy = JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "healthy" }, +}); +const runningStarting = JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "starting" }, +}); +const notRunning = JSON.stringify({ Status: "exited", Running: false }); + +/** + * `legacyWaitForHealthyServices` structurally requires `HttpClient.HttpClient` + * (only exercised on the PostgREST HTTP-HEAD branch) — every test provides + * some `HttpClient.HttpClient`, and this one fails loudly if a + * container-only-health-check test ever calls it unexpectedly. + */ +const unusedHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("HttpClient should not be called for a plain container check")), +); + +describe("legacyWaitForHealthyServices", () => { + it.effect( + "polls on a 1-second backoff until the container reports healthy, without waiting a full timeout", + () => + Effect.gen(function* () { + const mock = mockHealthSpawner((_id, callIndex) => + callIndex === 0 ? runningStarting : runningHealthy, + ); + + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_kong_proj"], { + timeoutSeconds: 30, + }).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + + expect(Exit.isSuccess(exit)).toBe(true); + const inspectCalls = mock.spawned.filter( + (args) => args[0] === "container" && args[1] === "inspect", + ); + expect(inspectCalls).toHaveLength(2); + }), + ); + + it.effect( + "stops probing a container once it becomes healthy, and only reports the still-unhealthy one on timeout", + () => + Effect.gen(function* () { + const mock = mockHealthSpawner((containerId) => + containerId === "supabase_kong_proj" ? runningHealthy : notRunning, + ); + + const fiber = yield* legacyWaitForHealthyServices( + mock.spawner, + ["supabase_kong_proj", "supabase_rest_proj"], + { timeoutSeconds: 2 }, + ).pipe(Effect.provide(unusedHttpClientLayer), Effect.forkChild({ startImmediately: true })); + + // 2 retries after the initial attempt (Go's `WithMaxRetries(..., timeout.Seconds())`). + yield* TestClock.adjust("1 seconds"); + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + + const kongCalls = mock.spawned.filter( + (args) => + args[0] === "container" && args[1] === "inspect" && args[2] === "supabase_kong_proj", + ); + const restCalls = mock.spawned.filter( + (args) => + args[0] === "container" && args[1] === "inspect" && args[2] === "supabase_rest_proj", + ); + // The healthy container is probed exactly once, then narrowed out of + // the "still watching" set — the unhealthy one is probed on every round. + expect(kongCalls).toHaveLength(1); + expect(restCalls).toHaveLength(3); + }), + ); + + it.effect( + "fails with LegacyHealthCheckTimeoutError carrying only the still-unhealthy container's reason", + () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning); + + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + }).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyHealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_rest_proj", reason: "container is not running: exited" }, + ]); + expect(error.message).toBe("supabase_rest_proj: container is not running: exited"); + }), + ); + + it.effect("dumps container logs to stderr on a genuine timeout", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning); + const writes: Array = []; + const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); + globalThis.process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof globalThis.process.stderr.write; + + try { + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + }).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("1 seconds"); + yield* Fiber.await(fiber); + } finally { + globalThis.process.stderr.write = originalWrite; + } + + expect(writes.some((chunk) => chunk.includes("supabase_rest_proj container logs:"))).toBe( + true, + ); + expect( + mock.spawned.some((args) => args[0] === "logs" && args[1] === "supabase_rest_proj"), + ).toBe(true); + }), + ); + + describe("PostgREST HTTP-HEAD readiness", () => { + function postgrestGateway(secretKey: string): LegacyHealthCheckPostgrestGateway { + return { + containerId: "supabase_rest_proj", + apiExternalUrl: "http://127.0.0.1:54321", + secretKey, + }; + } + + function httpLayer(status: number, expectHeaders: (headers: Record) => void) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + expectHeaders({ ...request.headers }); + expect(request.method).toBe("HEAD"); + expect(request.url).toBe("http://127.0.0.1:54321/rest-admin/v1/ready"); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status })), + ); + }), + ); + } + + it.effect("bypasses the Docker healthcheck and succeeds on a 200 HEAD response", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(200, (headers) => { + expect(headers["apikey"]).toBe("sb_secret_local"); + expect(headers["authorization"]).toBeUndefined(); + }); + + const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + postgrest: postgrestGateway("sb_secret_local"), + }).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + // Never falls back to the Docker healthcheck for PostgREST. + expect(mock.spawned.some((args) => args[0] === "container" && args[1] === "inspect")).toBe( + false, + ); + }), + ); + + it.effect("sends both apikey and Authorization headers for a JWT secret key", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(200, (headers) => { + expect(headers["apikey"]).toBe("ey.jwt.key"); + expect(headers["authorization"]).toBe("Bearer ey.jwt.key"); + }); + + const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + postgrest: postgrestGateway("ey.jwt.key"), + }).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + }), + ); + + it.effect("retries and eventually times out when PostgREST never returns 200", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(503, () => {}); + + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + postgrest: postgrestGateway("sb_secret_local"), + }).pipe(Effect.provide(layer), Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyHealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_rest_proj", reason: "unexpected status 503" }, + ]); + }), + ); + }); + + describe("Edge Runtime HTTP-HEAD readiness", () => { + function edgeRuntimeGateway(secretKey: string): LegacyHealthCheckPostgrestGateway { + return { + containerId: "supabase_edge_runtime_proj", + apiExternalUrl: "http://127.0.0.1:54321", + secretKey, + }; + } + + function httpLayer(status: number, expectPath: string) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.method).toBe("HEAD"); + expect(request.url).toBe(`http://127.0.0.1:54321${expectPath}`); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status })), + ); + }), + ); + } + + it.effect("bypasses the Docker healthcheck and succeeds on a 200 HEAD response", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(200, "/functions/v1/_internal/health"); + + const exit = yield* legacyWaitForHealthyServices( + mock.spawner, + ["supabase_edge_runtime_proj"], + { + timeoutSeconds: 1, + edgeRuntime: edgeRuntimeGateway("sb_secret_local"), + }, + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + // Never falls back to the Docker healthcheck for Edge Runtime. + expect(mock.spawned.some((args) => args[0] === "container" && args[1] === "inspect")).toBe( + false, + ); + }), + ); + + it.effect("retries and eventually times out when Edge Runtime never returns 200", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(503, "/functions/v1/_internal/health"); + + const fiber = yield* legacyWaitForHealthyServices( + mock.spawner, + ["supabase_edge_runtime_proj"], + { + timeoutSeconds: 1, + edgeRuntime: edgeRuntimeGateway("sb_secret_local"), + }, + ).pipe(Effect.provide(layer), Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyHealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_edge_runtime_proj", reason: "unexpected status 503" }, + ]); + }), + ); + + it.effect( + "probes PostgREST and Edge Runtime on their own paths, and every other container via Docker", + () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + expect(["/rest-admin/v1/ready", "/functions/v1/_internal/health"]).toContain( + url.pathname, + ); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ); + }), + ); + + const exit = yield* legacyWaitForHealthyServices( + mock.spawner, + ["supabase_rest_proj", "supabase_edge_runtime_proj", "supabase_kong_proj"], + { + timeoutSeconds: 1, + postgrest: { + containerId: "supabase_rest_proj", + apiExternalUrl: "http://127.0.0.1:54321", + secretKey: "sb_secret_local", + }, + edgeRuntime: edgeRuntimeGateway("sb_secret_local"), + }, + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + expect( + mock.spawned.some( + (args) => + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_kong_proj", + ), + ).toBe(true); + expect( + mock.spawned.some( + (args) => + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_rest_proj", + ), + ).toBe(false); + expect( + mock.spawned.some( + (args) => + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_edge_runtime_proj", + ), + ).toBe(false); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/image-prepull.ts b/apps/cli/src/legacy/commands/start/lib/image-prepull.ts new file mode 100644 index 0000000000..bf43bd04ee --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/image-prepull.ts @@ -0,0 +1,95 @@ +/** + * Port of Go's `ensureImagesCached` (`apps/cli-go/internal/start/start.go:225-262`): + * guarantees every image `supabase start` needs is resolved/pulled into the + * local Docker cache BEFORE any container is created, using the same + * multi-registry fallback as the per-container start path + * (`legacyMakeDockerImageResolver`). + * + * Go's caller (`internal/start/start.go:264-291`, `run`) also runs a + * best-effort `docker-compose`-based pre-pull first + * (`pullImagesUsingCompose`) and treats `ensureImagesCached` as the hard-failing + * backstop that catches whatever the compose pre-pull's `IgnoreFailures` step + * skipped. This port does not implement docker-compose integration anywhere + * (an intentional architecture decision for this port), so + * {@link legacyEnsureImagesCached} is the ONLY image pre-pull step here, not a + * backstop for a separate best-effort pass — every image must resolve through + * this call before any container starts. + */ + +import { Data, Effect, Result } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyMakeDockerImageResolver } from "../../../shared/legacy-docker-image-resolve.ts"; +import { + LEGACY_SUGGEST_DOCKER_INSTALL, + legacyIsDockerDaemonUnreachable, +} from "../../../shared/legacy-docker-suggest.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** + * One or more images failed to resolve/pull from every registry candidate. + * Mirrors Go's `errors.Join(result...)` (`start.go:257`): the message + * aggregates every failed image's own error rather than surfacing only the + * first failure, so a caller can see every broken image in one report. + */ +export class LegacyImagePrepullError extends Data.TaggedError("LegacyImagePrepullError")<{ + readonly message: string; +}> {} + +/** + * Resolves every image in `images` concurrently (Go's `utils.WaitAll` — + * unbounded goroutines) via the shared registry-fallback resolver, and returns + * a map from the ORIGINAL image reference to the resolved image URL a caller + * must use to reference that image afterward (e.g. as + * `LegacyStartContainerSpec.image`) — resolving the same image twice would be + * wasteful and could, in theory, land on a different registry candidate on a + * second, independent call. + * + * `images` is deduped here (Go's `seen := map[string]struct{}{}`, + * `start.go:238-249`) — callers are not expected to have already deduped their + * service image list. + */ +export function legacyEnsureImagesCached( + spawner: Spawner, + images: ReadonlyArray, + projectEnvValues?: Readonly>, +): Effect.Effect, LegacyImagePrepullError> { + const uniqueImages = [...new Set(images)]; + const resolveImage = legacyMakeDockerImageResolver(spawner, projectEnvValues); + + return Effect.gen(function* () { + const results = yield* Effect.all( + uniqueImages.map((image) => resolveImage(image).pipe(Effect.result)), + { concurrency: "unbounded" }, + ); + + const resolved = new Map(); + const failures: Array = []; + for (const [index, image] of uniqueImages.entries()) { + const result = results[index]; + if (result === undefined || Result.isFailure(result)) { + failures.push(result === undefined ? `${image}: unknown error` : result.failure.message); + continue; + } + resolved.set(image, result.success); + } + + if (failures.length > 0) { + // Go sets the install hint once, sequentially, after the concurrent + // resolve finishes (`SuggestDockerInstallIfConnectionFailed`, + // `start.go:254-259`) rather than from inside the resolver itself, where + // concurrent goroutines would race on the shared `CmdSuggestion` global. + // There is no such global here, so the hint is appended directly onto + // the joined message instead. + const hint = failures.some(legacyIsDockerDaemonUnreachable) + ? `\n\n${LEGACY_SUGGEST_DOCKER_INSTALL}` + : ""; + return yield* Effect.fail( + new LegacyImagePrepullError({ message: `${failures.join("\n")}${hint}` }), + ); + } + + return resolved; + }); +} diff --git a/apps/cli/src/legacy/commands/start/lib/image-prepull.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/image-prepull.unit.test.ts new file mode 100644 index 0000000000..716eddc854 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/image-prepull.unit.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Ref, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { LegacyImagePrepullError, legacyEnsureImagesCached } from "./image-prepull.ts"; + +/** Matches the standing `mockSpawner` shape in `legacy-docker-lifecycle.unit.test.ts`, generalized to a per-call handler so each argv can respond differently (needed for "some images cached, others not"). */ +function mockSpawner( + handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, +) { + const encoder = new TextEncoder(); + const spawned: Array> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + const result = handler(args); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + 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), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +describe("legacyEnsureImagesCached", () => { + it.live("dedupes images before resolving, returning original ref -> resolved URL", () => { + const mock = mockSpawner((args) => { + if (args[0] === "image" && args[1] === "inspect") { + const image = args[2]; + const cached = + image === "public.ecr.aws/supabase/postgres:15" || + image === "public.ecr.aws/supabase/kong:3"; + return { exitCode: cached ? 0 : 1 }; + } + return { exitCode: 1 }; + }); + + return legacyEnsureImagesCached(mock.spawner, [ + "supabase/postgres:15", + "supabase/kong:3", + "supabase/postgres:15", + ]).pipe( + Effect.map((resolved) => { + expect(resolved).toEqual( + new Map([ + ["supabase/postgres:15", "public.ecr.aws/supabase/postgres:15"], + ["supabase/kong:3", "public.ecr.aws/supabase/kong:3"], + ]), + ); + // One `image inspect` call per UNIQUE image, not one per (duplicated) input entry. + const inspectCalls = mock.spawned.filter( + (call) => call[0] === "image" && call[1] === "inspect", + ); + expect(inspectCalls).toHaveLength(2); + }), + ); + }); + + it.live("resolves every image concurrently rather than one at a time", () => + Effect.gen(function* () { + const started = yield* Ref.make(0); + const bothStarted = yield* Deferred.make(); + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + if (args[0] === "image" && args[1] === "inspect") { + const count = yield* Ref.updateAndGet(started, (n) => n + 1); + if (count < 2) { + // A sequential (non-concurrent) implementation would never let the + // second image's `image inspect` call start until this one + // returns, so awaiting here would hang forever — proving + // concurrency is what lets this test complete at all. + yield* Deferred.await(bothStarted); + } else { + yield* Deferred.succeed(bothStarted, undefined); + } + } + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + const resolved = yield* legacyEnsureImagesCached(spawner, ["supabase/a:1", "supabase/b:1"]); + expect(resolved.size).toBe(2); + }), + ); + + // Every pull attempt fails, so this drives the real DOCKER_PULL_RETRY_DELAYS_MS + // backoff (4s + 8s) to exhaustion across all 3 registry candidates (~36s) — + // needs more than Vitest's 5s default. + it.live( + "aggregates every failed image's message into one combined error", + () => { + const mock = mockSpawner((args) => { + if (args[0] === "image" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "pull") return { exitCode: 1, stderr: `no such image: ${args[1]}\n` }; + return { exitCode: 1 }; + }); + + return legacyEnsureImagesCached(mock.spawner, ["supabase/a:1", "supabase/b:1"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyImagePrepullError); + expect(error.message).toContain("supabase/a:1"); + expect(error.message).toContain("supabase/b:1"); + }), + ); + }, + 60_000, + ); + + it.live( + "appends the install hint once when a failure indicates the daemon is unreachable", + () => { + const mock = mockSpawner((args) => { + if (args[0] === "image" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "pull") { + return { + exitCode: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock\n", + }; + } + return { exitCode: 1 }; + }); + + return legacyEnsureImagesCached(mock.spawner, ["supabase/a:1"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toContain("Docker Desktop is a prerequisite for local development"); + }), + ); + }, + 60_000, + ); + + it.live("resolves an empty map for an empty image list without spawning anything", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyEnsureImagesCached(mock.spawner, []).pipe( + Effect.map((resolved) => { + expect(resolved.size).toBe(0); + expect(mock.spawned).toHaveLength(0); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/internal-db-connection.ts b/apps/cli/src/legacy/commands/start/lib/internal-db-connection.ts new file mode 100644 index 0000000000..7f7dd46b06 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/internal-db-connection.ts @@ -0,0 +1,56 @@ +/** + * The fixed, in-Docker-network Postgres address every `start`-created service + * container (Realtime, PostgREST, Storage) reaches Postgres at, ported from + * Go's `dbConfig := pgconn.Config{Host: utils.DbId, Port: 5432, User: + * "postgres", Password: utils.Config.Db.Password, Database: "postgres"}` + * (`apps/cli-go/internal/start/start.go:66-72`, threaded through `run()` as + * `dbConfig`). + * + * This is deliberately NOT the same value as + * `LegacyLocalConfigValues.dbUrl` (`legacy-local-config-values.ts`): that + * field is `postgresql://postgres:@:/postgres` + * — the HOST-facing address `status`/`stop` print for a user's own `psql` + * client, reachable through Docker's published port mapping. Every + * container-to-container env var in `start.go` instead reaches Postgres via + * `utils.DbId` (the `db` container's own Docker name) on its unpublished, + * always-5432 internal port — the two never share a value except by + * coincidence (`db.port` happening to equal 5432). + * + * Hoisted here (`start/lib/`, the `start` command family's shared root) per + * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule: Realtime, + * PostgREST, and Storage's own container-spec builders + * (`start/services/*.service.ts`) all need this exact host/port/password + * derivation. + */ + +/** Go's `dbConfig.Port` literal (`start.go:68`) — always 5432, never the configurable `db.port`. */ +export const LEGACY_START_INTERNAL_DB_PORT = 5432; + +/** Go's `dbConfig.Database` literal (`start.go:71`) — always `"postgres"`, never configurable. */ +export const LEGACY_START_INTERNAL_DB_NAME = "postgres"; + +/** + * Extracts `dbConfig.Password` (Go's `utils.Config.Db.Password`, `db.go:88` — + * `toml:"-"`, never configurable, always the `"postgres"` literal default, + * `pkg/config/config.go:459`) from the already-resolved + * `LegacyLocalConfigValues.dbUrl` rather than re-deriving that default a + * second time — `dbUrl`'s shape + * (`postgresql://postgres:@:/postgres`, + * `legacyResolveLocalConfigValues`) always embeds the exact same password + * value `dbConfig.Password` does, since both are sourced from the same + * `config.Db.Password` field. + */ +export function legacyStartInternalDbPassword(dbUrl: string): string { + return new URL(dbUrl).password; +} + +/** + * `:@:5432/postgres` — the shape of every + * container-to-container Postgres URI `start.go` builds for a specific role + * (PostgREST's `PGRST_DB_URI` as `authenticator`, Storage's `DATABASE_URL` as + * `supabase_storage_admin`, Storage's vector-bucket default `VECTOR_DATABASE_URL` + * as `postgres` — see `appendStorageVectorEnv`, `start.go:1487-1501`). + */ +export function legacyStartInternalDbUrl(role: string, dbHost: string, dbPassword: string): string { + return `postgresql://${role}:${dbPassword}@${dbHost}:${LEGACY_START_INTERNAL_DB_PORT}/${LEGACY_START_INTERNAL_DB_NAME}`; +} diff --git a/apps/cli/src/legacy/commands/start/lib/internal-db-connection.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/internal-db-connection.unit.test.ts new file mode 100644 index 0000000000..25f27fdc1d --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/internal-db-connection.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; + +import { + LEGACY_START_INTERNAL_DB_NAME, + LEGACY_START_INTERNAL_DB_PORT, + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "./internal-db-connection.ts"; + +describe("legacyStartInternalDbPassword", () => { + test("extracts the password component from a resolved dbUrl", () => { + expect( + legacyStartInternalDbPassword("postgresql://postgres:postgres@127.0.0.1:54322/postgres"), + ).toBe("postgres"); + }); + + test("extracts a non-default password", () => { + expect( + legacyStartInternalDbPassword("postgresql://postgres:super-secret@127.0.0.1:54322/postgres"), + ).toBe("super-secret"); + }); +}); + +describe("legacyStartInternalDbUrl", () => { + test("builds a role-specific internal connection string on the fixed port/database", () => { + expect(legacyStartInternalDbUrl("authenticator", "supabase_db_proj", "postgres")).toBe( + `postgresql://authenticator:postgres@supabase_db_proj:${LEGACY_START_INTERNAL_DB_PORT}/${LEGACY_START_INTERNAL_DB_NAME}`, + ); + }); + + test("port and database name are always the fixed internal values", () => { + expect(LEGACY_START_INTERNAL_DB_PORT).toBe(5432); + expect(LEGACY_START_INTERNAL_DB_NAME).toBe("postgres"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts new file mode 100644 index 0000000000..1d1756256b --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts @@ -0,0 +1,25 @@ +/** + * Go's `envOrDefault(key, def string) string` (`start.go:1466-1471`): + * `os.LookupEnv`-if-set-else-default — an env var that is SET but empty is + * used verbatim (unlike `legacy-local-config-values.ts`'s `envOverride`, + * which treats an empty resolved value as unset). `projectEnvValues` mirrors + * that module's own merged (dotenv + ambient shell, ambient-wins) map; + * `??` only skips a `null`/`undefined` operand, never an empty string, so + * this naturally reproduces `LookupEnv`'s "ok if set, even if empty" + * semantics without a separate presence check. No `SUPABASE_` prefix and no + * `env(VAR)` indirection — Go's raw `os.LookupEnv` here bypasses the + * mapstructure decode-hook chain those only apply to. + * + * Hoisted here (`start/lib/`, the `start` command family's shared root) per + * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule: Storage's + * vector-bucket env (`services/storage.service.ts`) and Kong's + * `KONG_NGINX_WORKER_PROCESSES` (`services/kong.service.ts`) both need this + * exact env/dotenv-vs-default derivation. + */ +export function legacyEnvOrDefault( + key: string, + def: string, + projectEnvValues: Readonly> | undefined, +): string { + return projectEnvValues?.[key] ?? process.env[key] ?? def; +} diff --git a/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.unit.test.ts new file mode 100644 index 0000000000..44f19d6861 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.unit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; + +import { legacyEnvOrDefault } from "./legacy-env-or-default.ts"; + +describe("legacyEnvOrDefault", () => { + test('falls back to the default when unset anywhere (Go\'s "envOrDefault", start.go:1466-1471)', () => { + expect(legacyEnvOrDefault("LEGACY_ENV_OR_DEFAULT_UNSET_KEY", "default", undefined)).toBe( + "default", + ); + }); + + test("prefers a projectEnvValues (dotenv) value over the default", () => { + expect(legacyEnvOrDefault("SOME_KEY", "default", { SOME_KEY: "from-dotenv" })).toBe( + "from-dotenv", + ); + }); + + test("an override that is set but empty is used verbatim, matching os.LookupEnv (not treated as unset)", () => { + expect(legacyEnvOrDefault("SOME_KEY", "default", { SOME_KEY: "" })).toBe(""); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/template-render.ts b/apps/cli/src/legacy/commands/start/lib/template-render.ts new file mode 100644 index 0000000000..de266f9aa9 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/template-render.ts @@ -0,0 +1,131 @@ +/** + * Pure renderer for the Go `text/template` sources transcribed under + * `../templates/`. Mirrors `apps/cli-go/internal/start/start.go`'s use of + * `text/template`: every placeholder is a bare `{{ .FieldName }}` reference + * (no pipelines, functions, or control structures), and every one of the + * three templates rendered here is executed with `Option("missingkey=error")` + * (verified via `grep -n '\.Execute(' apps/cli-go/internal/start/start.go`, + * lines 399, 489, 1203) — so a placeholder with no matching field always + * throws, never silently renders Go's ``. + * + * `custom_nginx.template` is deliberately NOT rendered here: it is not a Go + * `text/template` at all (see the comment on + * `LEGACY_START_CUSTOM_NGINX_TEMPLATE`) and is injected byte-for-byte, with + * its `${{VAR}}` placeholders substituted by Kong itself at container boot. + * Callers needing that file should import `LEGACY_START_CUSTOM_NGINX_TEMPLATE` + * directly from `../templates/custom_nginx.template.ts`. + */ +import { LEGACY_START_KONG_YML_TEMPLATE } from "../templates/kong.yml.ts"; +import { LEGACY_START_POOLER_EXS_TEMPLATE } from "../templates/pooler.exs.ts"; +import { LEGACY_START_VECTOR_YAML_TEMPLATE } from "../templates/vector.yaml.ts"; + +const GO_TEMPLATE_FIELD_PATTERN = /\{\{\s*\.(\w+)\s*\}\}/g; + +/** + * Substitutes every `{{ .FieldName }}` occurrence in `template` with the + * matching value from `fields`, replicating Go's `text/template` rendered + * with `Option("missingkey=error")`: a placeholder referencing a field not + * present in `fields` throws instead of rendering ``. + */ +export function legacyRenderGoTemplate( + template: string, + fields: Readonly>, +): string { + return template.replace(GO_TEMPLATE_FIELD_PATTERN, (_match, fieldName: string) => { + if (!Object.hasOwn(fields, fieldName)) { + throw new Error( + `legacyRenderGoTemplate: template references undefined field ".${fieldName}" (missingkey=error)`, + ); + } + return String(fields[fieldName]); + }); +} + +export interface LegacyStartKongYmlFields { + readonly gotrueId: string; + readonly restId: string; + readonly realtimeId: string; + readonly storageId: string; + readonly studioId: string; + readonly pgmetaId: string; + readonly edgeRuntimeId: string; + readonly logflareId: string; + readonly poolerId: string; + readonly apiHost: string; + readonly apiPort: number; + readonly bearerToken: string; + readonly queryToken: string; +} + +/** Renders `kong.yml` from Go's `kongConfig` struct (`start.go:90-104`). */ +export function legacyRenderStartKongYml(fields: LegacyStartKongYmlFields): string { + return legacyRenderGoTemplate(LEGACY_START_KONG_YML_TEMPLATE, { + GotrueId: fields.gotrueId, + RestId: fields.restId, + RealtimeId: fields.realtimeId, + StorageId: fields.storageId, + StudioId: fields.studioId, + PgmetaId: fields.pgmetaId, + EdgeRuntimeId: fields.edgeRuntimeId, + LogflareId: fields.logflareId, + PoolerId: fields.poolerId, + ApiHost: fields.apiHost, + ApiPort: fields.apiPort, + BearerToken: fields.bearerToken, + QueryToken: fields.queryToken, + }); +} + +export interface LegacyStartVectorYamlFields { + readonly apiKey: string; + readonly vectorId: string; + readonly logflareId: string; + readonly kongId: string; + readonly gotrueId: string; + readonly restId: string; + readonly realtimeId: string; + readonly storageId: string; + readonly edgeRuntimeId: string; + readonly dbId: string; +} + +/** Renders `vector.yaml` from Go's `vectorConfig` struct (`start.go:118-129`). */ +export function legacyRenderStartVectorYaml(fields: LegacyStartVectorYamlFields): string { + return legacyRenderGoTemplate(LEGACY_START_VECTOR_YAML_TEMPLATE, { + ApiKey: fields.apiKey, + VectorId: fields.vectorId, + LogflareId: fields.logflareId, + KongId: fields.kongId, + GotrueId: fields.gotrueId, + RestId: fields.restId, + RealtimeId: fields.realtimeId, + StorageId: fields.storageId, + EdgeRuntimeId: fields.edgeRuntimeId, + DbId: fields.dbId, + }); +} + +export interface LegacyStartPoolerExsFields { + readonly dbHost: string; + readonly dbPort: number; + readonly dbDatabase: string; + readonly dbPassword: string; + readonly externalId: string; + readonly modeType: string; + readonly defaultMaxClients: number; + readonly defaultPoolSize: number; +} + +/** Renders `pooler.exs` from Go's `poolerTenant` struct (`start.go:144-153`). */ +export function legacyRenderStartPoolerExs(fields: LegacyStartPoolerExsFields): string { + return legacyRenderGoTemplate(LEGACY_START_POOLER_EXS_TEMPLATE, { + DbHost: fields.dbHost, + DbPort: fields.dbPort, + DbDatabase: fields.dbDatabase, + DbPassword: fields.dbPassword, + ExternalId: fields.externalId, + ModeType: fields.modeType, + DefaultMaxClients: fields.defaultMaxClients, + DefaultPoolSize: fields.defaultPoolSize, + }); +} diff --git a/apps/cli/src/legacy/commands/start/lib/template-render.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/template-render.unit.test.ts new file mode 100644 index 0000000000..ba8c16e78b --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/template-render.unit.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; + +import { LEGACY_START_KONG_YML_TEMPLATE } from "../templates/kong.yml.ts"; +import { LEGACY_START_POOLER_EXS_TEMPLATE } from "../templates/pooler.exs.ts"; +import { LEGACY_START_VECTOR_YAML_TEMPLATE } from "../templates/vector.yaml.ts"; +import { + legacyRenderGoTemplate, + legacyRenderStartKongYml, + legacyRenderStartPoolerExs, + legacyRenderStartVectorYaml, + type LegacyStartKongYmlFields, + type LegacyStartPoolerExsFields, + type LegacyStartVectorYamlFields, +} from "./template-render.ts"; + +const kongFields: LegacyStartKongYmlFields = { + gotrueId: "supabase_auth_test", + restId: "supabase_rest_test", + realtimeId: "supabase_realtime_test", + storageId: "supabase_storage_test", + studioId: "supabase_studio_test", + pgmetaId: "supabase_pg_meta_test", + edgeRuntimeId: "supabase_edge_runtime_test", + logflareId: "supabase_analytics_test", + poolerId: "supabase_pooler_test", + apiHost: "127.0.0.1", + apiPort: 54321, + bearerToken: "test-bearer-token", + queryToken: "test-query-token", +}; + +const vectorFields: LegacyStartVectorYamlFields = { + apiKey: "test-api-key", + vectorId: "supabase_vector_test", + logflareId: "supabase_analytics_test", + kongId: "supabase_kong_test", + gotrueId: "supabase_auth_test", + restId: "supabase_rest_test", + realtimeId: "supabase_realtime_test", + storageId: "supabase_storage_test", + edgeRuntimeId: "supabase_edge_runtime_test", + dbId: "supabase_db_test", +}; + +const poolerFields: LegacyStartPoolerExsFields = { + dbHost: "supabase_db_test", + dbPort: 6543, + dbDatabase: "postgres", + dbPassword: "postgres", + externalId: "supabase_pooler_test", + modeType: "transaction", + defaultMaxClients: 100, + defaultPoolSize: 20, +}; + +describe("legacyRenderGoTemplate", () => { + it("substitutes bare {{ .Field }} placeholders", () => { + expect(legacyRenderGoTemplate("hello {{ .Name }}", { Name: "world" })).toBe("hello world"); + }); + + it("tolerates any amount of whitespace inside the braces", () => { + expect(legacyRenderGoTemplate("{{.Name}} {{ .Name }} {{ .Name }}", { Name: "x" })).toBe( + "x x x", + ); + }); + + it("renders numeric fields as base-10 strings with no added quotes or decimals", () => { + expect(legacyRenderGoTemplate("port={{ .Port }}", { Port: 6543 })).toBe("port=6543"); + }); + + it("throws when a referenced field is missing (missingkey=error parity)", () => { + expect(() => legacyRenderGoTemplate("{{ .Missing }}", {})).toThrow(/\.Missing/); + }); + + it("does not error on struct fields that exist but are never referenced", () => { + expect(legacyRenderGoTemplate("{{ .Used }}", { Used: "a", Unused: "b" })).toBe("a"); + }); +}); + +describe("legacyRenderStartKongYml", () => { + it("replaces every placeholder with no template syntax or missing-value markers left behind", () => { + const rendered = legacyRenderStartKongYml(kongFields); + expect(rendered).not.toContain("{{"); + expect(rendered).not.toContain("}}"); + expect(rendered).not.toContain(""); + expect(rendered).not.toContain("undefined"); + }); + + it("interpolates each service upstream URL from the matching container id", () => { + const rendered = legacyRenderStartKongYml(kongFields); + expect(rendered).toContain("url: http://supabase_auth_test:9999/verify"); + expect(rendered).toContain("url: http://supabase_rest_test:3000/"); + expect(rendered).toContain("url: http://supabase_realtime_test:4000/socket"); + expect(rendered).toContain("url: http://supabase_storage_test:5000/s3"); + expect(rendered).toContain("url: http://supabase_edge_runtime_test:8081/"); + expect(rendered).toContain("url: http://supabase_pg_meta_test:8080/"); + expect(rendered).toContain("url: http://supabase_analytics_test:4000/"); + expect(rendered).toContain("url: http://supabase_pooler_test:4000/v2"); + expect(rendered).toContain("url: http://supabase_studio_test:3000/api/mcp"); + }); + + it("interpolates the bearer and query tokens into every header/querystring reference", () => { + const rendered = legacyRenderStartKongYml(kongFields); + expect(rendered).toContain('"Authorization: test-bearer-token"'); + expect(rendered).toContain('"sb-api-key: test-bearer-token"'); + expect(rendered).toContain('"apikey:test-query-token"'); + expect(rendered.match(/test-bearer-token/g)).toHaveLength( + (LEGACY_START_KONG_YML_TEMPLATE.match(/\{\{ \.BearerToken \}\}/g) ?? []).length, + ); + }); + + it("throws referencing the missing Go struct field when a placeholder has no value", () => { + const { gotrueId: _gotrueId, ...withoutGotrue } = kongFields; + const rawFields: Record = { + RestId: withoutGotrue.restId, + RealtimeId: withoutGotrue.realtimeId, + StorageId: withoutGotrue.storageId, + StudioId: withoutGotrue.studioId, + PgmetaId: withoutGotrue.pgmetaId, + EdgeRuntimeId: withoutGotrue.edgeRuntimeId, + LogflareId: withoutGotrue.logflareId, + PoolerId: withoutGotrue.poolerId, + ApiHost: withoutGotrue.apiHost, + ApiPort: withoutGotrue.apiPort, + BearerToken: withoutGotrue.bearerToken, + QueryToken: withoutGotrue.queryToken, + }; + expect(() => legacyRenderGoTemplate(LEGACY_START_KONG_YML_TEMPLATE, rawFields)).toThrow( + /\.GotrueId/, + ); + }); +}); + +describe("legacyRenderStartVectorYaml", () => { + it("replaces every placeholder with no template syntax or missing-value markers left behind", () => { + const rendered = legacyRenderStartVectorYaml(vectorFields); + expect(rendered).not.toContain("{{"); + expect(rendered).not.toContain("}}"); + expect(rendered).not.toContain(""); + expect(rendered).not.toContain("undefined"); + }); + + it("interpolates the vector, router, and logflare sink fields", () => { + const rendered = legacyRenderStartVectorYaml(vectorFields); + expect(rendered).toContain('- "supabase_vector_test"'); + expect(rendered).toContain("kong: '.appname == \"supabase_kong_test\"'"); + expect(rendered).toContain("auth: '.appname == \"supabase_auth_test\"'"); + expect(rendered).toContain("rest: '.appname == \"supabase_rest_test\"'"); + expect(rendered).toContain("realtime: '.appname == \"supabase_realtime_test\"'"); + expect(rendered).toContain("storage: '.appname == \"supabase_storage_test\"'"); + expect(rendered).toContain("functions: '.appname == \"supabase_edge_runtime_test\"'"); + expect(rendered).toContain("db: '.appname == \"supabase_db_test\"'"); + expect(rendered).toContain('x-api-key: "test-api-key"'); + expect(rendered).toContain( + 'uri: "http://supabase_analytics_test:4000/api/logs?source_name=gotrue.logs.prod"', + ); + }); + + it("throws referencing the missing Go struct field when a placeholder has no value", () => { + const { apiKey: _apiKey, ...withoutApiKey } = vectorFields; + const rawFields: Record = { + VectorId: withoutApiKey.vectorId, + LogflareId: withoutApiKey.logflareId, + KongId: withoutApiKey.kongId, + GotrueId: withoutApiKey.gotrueId, + RestId: withoutApiKey.restId, + RealtimeId: withoutApiKey.realtimeId, + StorageId: withoutApiKey.storageId, + EdgeRuntimeId: withoutApiKey.edgeRuntimeId, + DbId: withoutApiKey.dbId, + }; + expect(() => legacyRenderGoTemplate(LEGACY_START_VECTOR_YAML_TEMPLATE, rawFields)).toThrow( + /\.ApiKey/, + ); + }); +}); + +describe("legacyRenderStartPoolerExs", () => { + it("renders the exact expected Elixir source", () => { + expect(legacyRenderStartPoolerExs(poolerFields)).toBe( + `{:ok, _} = Application.ensure_all_started(:supavisor) + +{:ok, version} = + case Supavisor.Repo.query!("select version()") do + %{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver) + _ -> nil + end + +params = %{ + "external_id" => "supabase_pooler_test", + "db_host" => "supabase_db_test", + "db_port" => 6543, + "db_database" => "postgres", + "require_user" => false, + "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", + "default_max_clients" => 100, + "default_pool_size" => 20, + "default_parameter_status" => %{"server_version" => version}, + "users" => [%{ + "db_user" => "pgbouncer", + "db_password" => "postgres", + "mode_type" => "transaction", + "pool_size" => 20, + "is_manager" => true + }] +} + +if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + {:ok, _} = Supavisor.Tenants.create_tenant(params) +end +`, + ); + }); + + it("renders numeric fields as bare Elixir integer literals with no quotes or decimals", () => { + const rendered = legacyRenderStartPoolerExs(poolerFields); + expect(rendered).toContain('"db_port" => 6543,'); + expect(rendered).toContain('"default_max_clients" => 100,'); + expect(rendered).toContain('"default_pool_size" => 20,'); + expect(rendered).toContain('"pool_size" => 20,'); + expect(rendered).not.toMatch(/"db_port" => "6543"/); + expect(rendered).not.toMatch(/"db_port" => 6543\.0/); + }); + + it("throws referencing the missing Go struct field when a placeholder has no value", () => { + const { dbHost: _dbHost, ...withoutDbHost } = poolerFields; + const rawFields: Record = { + DbPort: withoutDbHost.dbPort, + DbDatabase: withoutDbHost.dbDatabase, + DbPassword: withoutDbHost.dbPassword, + ExternalId: withoutDbHost.externalId, + ModeType: withoutDbHost.modeType, + DefaultMaxClients: withoutDbHost.defaultMaxClients, + DefaultPoolSize: withoutDbHost.defaultPoolSize, + }; + expect(() => legacyRenderGoTemplate(LEGACY_START_POOLER_EXS_TEMPLATE, rawFields)).toThrow( + /\.DbHost/, + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts new file mode 100644 index 0000000000..8c996aca65 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts @@ -0,0 +1,331 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { beforeEach } from "vitest"; + +import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + legacyStartEdgeRuntimeContainer, + type LegacyEdgeRuntimeBringUpInput, +} from "./edge-runtime.service.ts"; + +/** + * A spawner that answers every `docker` invocation + * `legacyStartEdgeRuntimeContainer`'s call chain makes + * (`ensureDockerNamedVolume`/`ensureDockerNetwork`/the `docker run -d` bring-up + * itself) with success, recording every invocation's argv for assertions — + * same shape as `health-check.unit.test.ts`'s `mockHealthSpawner`. Secret + * values are delivered to the `run -d` call via `--env-file`/a bind-mounted + * script, not this spawned process's own environment (see + * `edge-runtime.service.ts`'s header for why), so there is nothing to capture + * beyond argv. Note `legacyStartEdgeRuntimeContainer` never issues a + * `docker exec ... kong reload` — that only happens in `functions serve`'s own + * `restartEdgeRuntime`-equivalent wrapper (`shared/functions/serve.ts`'s + * `startEdgeRuntime`), not in the shared bring-up core this module calls + * directly — see the "does not reload Kong" test below. + */ +function mockDockerSpawner() { + const calls: Array<{ args: ReadonlyArray }> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + calls.push({ args }); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get calls() { + return calls; + }, + get runCall() { + return calls.find((call) => call.args[0] === "run" && call.args[1] === "-d"); + }, + }; +} + +function baseInput(workdir: string): LegacyEdgeRuntimeBringUpInput { + return { + projectId: "proj", + networkId: "supabase_network_proj", + image: "registry.example.com/supabase/edge-runtime:v1.74.2", + workdir, + // `authenticator:postgres@127.0.0.1:54322/postgres` — password "postgres", matching every + // other service's `dbUrl` input shape (`LegacyLocalConfigValues.dbUrl`). + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + apiPort: 54321, + edgeRuntimePolicy: "oneshot", + edgeRuntimeInspectorPort: 8083, + edgeRuntimeSecrets: {}, + configDeclaredFunctions: {}, + configFunctions: {}, + rawConfigFunctions: {}, + authArtifacts: { + publishableKey: "sb_publishable_local", + secretKey: "sb_secret_local", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + anonKey: "anon.jwt.value", + serviceRoleKey: "service-role.jwt.value", + jwks: '{"keys":[]}', + }, + debug: false, + platform: "darwin", + }; +} + +function envEntries(runCall: { + args: ReadonlyArray; + env?: Readonly>; +}) { + const envFileArgIndex = runCall.args.indexOf("--env-file"); + const envFilePath = runCall.args[envFileArgIndex + 1]; + expect(envFilePath).toBeDefined(); + return readFileSync(envFilePath!, "utf8") + .split("\n") + .filter((line) => line.length > 0); +} + +describe("legacyStartEdgeRuntimeContainer", () => { + const tempWorkdir = useLegacyTempWorkdir("supabase-edge-runtime-service-int-"); + + // An empty functions directory — every scenario here has zero declared + // functions (`configDeclaredFunctions`/`configFunctions` in `baseInput`), + // so nothing under it is ever read; it only needs to exist. + beforeEach(() => { + mkdirSync(join(tempWorkdir.current, "supabase", "functions"), { recursive: true }); + }); + + it.effect( + "sends the real internal db url (db container name, port 5432, config.db.password) — NOT functions serve's `db`-alias default", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const entries = envEntries(mock.runCall!); + expect(entries).toContain( + "SUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_proj:5432/postgres", + ); + }), + ); + + it.effect("passes every already-resolved auth artifact through unchanged", () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const entries = envEntries(mock.runCall!); + expect(entries).toContain("SUPABASE_ANON_KEY=anon.jwt.value"); + expect(entries).toContain("SUPABASE_SERVICE_ROLE_KEY=service-role.jwt.value"); + expect(entries).toContain( + "SUPABASE_INTERNAL_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long", + ); + expect(entries).toContain("SUPABASE_INTERNAL_PUBLISHABLE_KEY=sb_publishable_local"); + expect(entries).toContain("SUPABASE_INTERNAL_SECRET_KEY=sb_secret_local"); + expect(entries).toContain('SUPABASE_JWKS={"keys":[]}'); + expect(entries).toContain("SUPABASE_INTERNAL_HOST_PORT=54321"); + }), + ); + + it.effect( + "never applies functions serve's own CLI-only overrides (no inspector port, verifyJWT defaults on)", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const runCall = mock.runCall!; + expect(runCall.args).not.toContain("-p"); + expect(runCall.args.join(" ")).not.toContain("--inspect"); + }), + ); + + it.effect( + "sets --workdir and --ulimit nofile=65536:65536, matching Go's WorkingDir/Ulimits container.Config", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const runCall = mock.runCall!; + const workdirIndex = runCall.args.indexOf("--workdir"); + expect(workdirIndex).toBeGreaterThanOrEqual(0); + expect(runCall.args[workdirIndex + 1]).toBe(tempWorkdir.current); + const ulimitIndex = runCall.args.indexOf("--ulimit"); + expect(runCall.args[ulimitIndex + 1]).toBe("nofile=65536:65536"); + }), + ); + + it.effect( + "stamps its own com.supabase.cli.workdir label so a later stop from a different cwd can reclaim this container's staged-secret directory", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const runCall = mock.runCall!; + expect(runCall.args).toContain(`com.supabase.cli.workdir=${tempWorkdir.current}`); + }), + ); + + it.effect( + "joins the caller-supplied network id and uses the already-resolved image, unmodified", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + const input = { + ...baseInput(tempWorkdir.current), + networkId: "custom_network_override", + image: "registry.example.com/supabase/edge-runtime:v1.99.9", + }; + + yield* legacyStartEdgeRuntimeContainer(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const runCall = mock.runCall!; + const networkIndex = runCall.args.indexOf("--network"); + expect(runCall.args[networkIndex + 1]).toBe("custom_network_override"); + expect(runCall.args).toContain("registry.example.com/supabase/edge-runtime:v1.99.9"); + }), + ); + + it.effect( + "resolves with the started container's id and a cleanup effect left for the caller to run", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + const started = yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + expect(started.containerId).toBe("supabase_edge_runtime_proj"); + yield* started.cleanup; + }), + ); + + it.effect( + "cleans up a stale staging directory from a previous invocation even when this invocation fails before ever reaching docker run", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + const stagingDir = join( + tempWorkdir.current, + "supabase", + ".temp", + "start-secrets", + "supabase_edge_runtime_proj", + ); + // Simulates a leftover from an earlier invocation (e.g. `functions serve`'s watch-mode + // restart loop) that was never reclaimed — `writeDockerEnvFile`'s own header explains why + // this path is deterministic/reused rather than a fresh mkdtemp per call. + mkdirSync(join(stagingDir, "env"), { recursive: true }); + writeFileSync(join(stagingDir, "env", "docker.env"), "STALE=1"); + + const input = { + ...baseInput(tempWorkdir.current), + // A multiline secret with a name that fails `validateDockerMultilineEnvNames` (must + // match a shell variable name) — this throws before any of THIS invocation's staging + // writes happen, proving cleanup covers the whole staging-write window, not just a + // failure at (or after) the `docker run` step. + edgeRuntimeSecrets: { "1BAD_NAME": "line one\nline two" }, + }; + + const exit = yield* Effect.exit( + legacyStartEdgeRuntimeContainer(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(existsSync(stagingDir)).toBe(false); + }), + ); + + it.effect( + "never reloads Kong — Go's `start` bring-up (`ServeFunctions`) never does, unlike `functions serve`'s own restart wrapper", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + expect( + mock.calls.some((call) => call.args[0] === "exec" && call.args.includes("kong")), + ).toBe(false); + }), + ); + + it.effect( + "never prints functions serve's own setup banner — Go's start bring-up (ServeFunctions) never does, unlike restartEdgeRuntime", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + expect(out.stderrText).not.toContain("Setting up Edge Functions runtime..."); + }), + ); +}); diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts new file mode 100644 index 0000000000..08f30eba9f --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts @@ -0,0 +1,205 @@ +/** + * Real bring-up for the Edge Runtime container — Go's "Start all functions" + * block (`apps/cli-go/internal/start/start.go:1101-1108`): + * + * ```go + * if utils.Config.EdgeRuntime.Enabled && !isContainerExcluded(utils.Config.EdgeRuntime.Image, excluded) { + * dbUrl := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s", dbConfig.User, dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database) + * if err := serve.ServeFunctions(ctx, "", nil, "", dbUrl, serve.RuntimeOption{}, fsys); err != nil { + * return err + * } + * started = append(started, utils.EdgeRuntimeId) + * } + * ``` + * + * Unlike its 12 siblings in this directory, this module does NOT build a + * `LegacyStartContainerSpec` for `legacyStartContainer` + * (`../lib/container-lifecycle.ts`) to create+start uniformly. That + * unification (`docker create`/`docker start`, `-e KEY`-only env with values + * supplied via the spawned process's own environment) was evaluated against + * what `shared/functions/serve.ts`'s `startEdgeRuntimeContainer` actually + * spawns and rejected as NOT a clean mapping: + * + * - `docker-create-args.ts`'s own header explicitly excludes `WorkingDir` + * and `Ulimits` from `LegacyStartContainerSpec` ("none of the 13 [other] + * call sites... set them") — Edge Runtime needs BOTH (`--workdir`, + * `--ulimit nofile=65536:65536`), so "mapping cleanly" would mean + * extending the shared spec for a single caller. + * - Every other service's env travels as bare `-e KEY` flags whose values + * come from the spawned `docker create` process's own environment + * (`container-lifecycle.ts`'s `legacyDockerCreateContainer`). Edge + * Runtime instead needs an `--env-file` for ordinary secrets AND a + * separate bind-mounted, `sh`-sourced multiline-env script for any secret + * containing a newline (`docker env-file` is a line-oriented format that + * cannot represent one) — a second env-delivery mechanism the shared spec + * has no concept of. + * - `docker run -d` (one atomic call) vs. `docker create` + `docker start` + * (two calls) is a structural argv difference, not a flag-mapping detail. + * + * So this module keeps `startEdgeRuntimeContainer`'s direct `docker run -d` + * exactly as `functions serve` already spawns it (see that module's own doc + * comment), and exposes {@link legacyStartEdgeRuntimeContainer} as a direct + * bring-up `Effect` for `start.handler.ts` to call from its own bring-up loop + * — NOT a spec for `legacyStartContainer` to create. `start.handler.ts`'s + * wiring must special-case Edge Runtime's bring-up call, the same way it + * already special-cases Postgres's (also called directly, not through the + * generic `buildSpecForService` switch, since it needs its own health-wait + * pass before the other services start). + * + * `SUPABASE_DB_URL` is the one thing that genuinely differs from what + * `functions serve` sends: standalone `functions serve` hardcodes the `db` + * network alias (`shared/functions/serve.ts`'s `legacyDefaultServeDbUrl`, + * matching Go's `restartEdgeRuntime`), but `start`'s own direct call + * (`start.go:66-72,1103`) uses the real `dbConfig` — the `db` container's own + * sanitized name and `config.db.password` — exactly like the other 12 + * services' `dbHost`/`dbPassword` derivation + * (`../lib/internal-db-connection.ts`). {@link legacyStartEdgeRuntimeContainer} + * reproduces that real value, not `functions serve`'s alias-based default. + */ + +import { Effect, Option } from "effect"; + +import { + startEdgeRuntimeContainer, + type ServeAuthArtifacts, + type ServeEdgeRuntimeContainerConfig, +} from "../../../../shared/functions/serve.ts"; +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import { + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "../lib/internal-db-connection.ts"; + +export interface LegacyEdgeRuntimeBringUpInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.EdgeRuntime.Image`, already resolved/pulled by the caller (`image-prepull.ts`/`legacyResolveEdgeRuntimeImage`). */ + readonly image: string; + /** + * Go's `cwd`/`utils.CurrentDirAbs` for this call — `cliConfig.workdir` in + * `start.handler.ts`. Used as `functions serve`'s `projectRoot`/`flagCwd` + * (no separate "flag cwd" exists for `start`) and to derive `supabaseDir` + * (`/supabase`). + */ + readonly workdir: string; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB host/password (matches every other service builder's own `dbUrl` input). */ + readonly dbUrl: string; + /** `config.api.port` — `SUPABASE_INTERNAL_HOST_PORT`. */ + readonly apiPort: number; + /** `config.edge_runtime.policy`. */ + readonly edgeRuntimePolicy: string; + /** `config.edge_runtime.inspector_port` — only published when `inspectMode` is set, which `start` never does (Go's `serve.RuntimeOption{}` zero value). */ + readonly edgeRuntimeInspectorPort: number; + /** + * `config.edge_runtime.secrets`, already unwrapped/uppercased — build via + * `shared/functions/serve.ts`'s exported `toPlainEdgeRuntimeConfig(config.edge_runtime).secrets` + * rather than re-deriving the `Redacted`-unwrap/uppercase logic here. + */ + readonly edgeRuntimeSecrets: Readonly>; + /** + * `config.functions`, already unwrapped — build via + * `shared/functions/serve.ts`'s exported `toPlainFunctionRecord(config.functions)`. + */ + readonly configDeclaredFunctions: ServeEdgeRuntimeContainerConfig["configDeclaredFunctions"]; + /** + * The functions directory manifest — build via `@supabase/config`'s + * `inferFunctionsManifest({ cwd: workdir, config })`, same as + * `shared/functions/serve.ts`'s own `resolveServeConfig` does. + */ + readonly configFunctions: ServeEdgeRuntimeContainerConfig["configFunctions"]; + /** + * The raw, pre-schema `[functions.*]` TOML table — build via + * `shared/functions/deploy.ts`'s exported `rawFunctionConfigRecord(document)` + * against the same raw document `start.handler.ts` already loads (its own + * `context.loaded?.document`). + */ + readonly rawConfigFunctions: ServeEdgeRuntimeContainerConfig["rawConfigFunctions"]; + /** + * Every already-resolved secret/key this container needs — `start`'s own + * `values.{publishableKey,secretKey,jwtSecret,anonKey,serviceRoleKey}` plus + * `jwks` (`legacyResolveLocalJwks`'s result), NOT independently re-resolved + * (see this module's header). + */ + readonly authArtifacts: ServeAuthArtifacts; + /** The global `--debug` flag. */ + readonly debug: boolean; + /** `RuntimeInfo.platform`/`process.platform` — gates the Linux-only `--add-host host.docker.internal:host-gateway` flag. */ + readonly platform: NodeJS.Platform; +} + +/** + * Bring up the Edge Runtime container for `start`, delegating the entire + * `docker run` argv/env assembly to `shared/functions/serve.ts`'s + * `startEdgeRuntimeContainer` (already ported for `functions serve`) with + * `start`'s own already-resolved config/secrets in place of that command's + * independent config-loading pipeline. `start.handler.ts`'s bring-up loop + * should call this directly (NOT `legacyStartContainer`) for the Edge Runtime + * entry in its service list, gated the same way as every other service on + * `config.edge_runtime.enabled && !isContainerExcluded(...)`. + * + * Resolves to the same `StartedRuntime` shape `functions serve` itself + * gets back. `containerId` is what the caller adds to its post-bring-up + * health-wait list (pairing it with an `edgeRuntime` gateway on + * `LegacyWaitForHealthyServicesOptions`, `../lib/health-check.ts` — the same + * shape as the existing `postgrest` gateway). `watchSpecs` is + * `functions serve`-only file-watch plumbing and can be ignored here. + * + * `cleanup` (removing the temp env-file/multiline-env-script/serve-main- + * template files this call writes to the host) is intentionally left to the + * caller, and the caller must NOT invoke it on a successful bring-up. Unlike + * every other `start` service (`legacyStartContainer`'s `restartPolicy: + * "unless-stopped"`), Go's own Edge Runtime bring-up (`serve.ServeFunctions`, + * `internal/functions/serve/serve.go:218-241`) sets NO Docker restart policy + * at all — its lifecycle is deliberately reconciled at the CLI level + * (`restartEdgeRuntime`, `serve.go:108-133`), not the Docker daemon level — + * so this container's own `docker run` (`shared/functions/serve.ts`) + * intentionally omits `--restart` too. Its bind-mounted host paths must + * still exist for as long as the container itself can be reattached to + * (e.g. a plain `docker start` by the user, or discovery by a later CLI + * invocation) — the same reasoning `legacyStageStartSecretFiles` + * (`../lib/container-lifecycle.ts`) already applies to every other service's + * staged secret files. `startEdgeRuntimeContainer` (`shared/functions/ + * serve.ts`) already runs `cleanup` internally on any failed or interrupted + * bring-up (`Effect.onError`, covering the whole staging-write-through- + * `docker run` window, not just a non-zero exit code), so the caller only + * needs to leave the returned `cleanup` unused on success. + */ +export const legacyStartEdgeRuntimeContainer = Effect.fn("legacy.start.edgeRuntime")(function* ( + input: LegacyEdgeRuntimeBringUpInput, +) { + return yield* startEdgeRuntimeContainer({ + config: { + projectId: input.projectId, + apiPort: input.apiPort, + edgeRuntimePolicy: input.edgeRuntimePolicy, + edgeRuntimeInspectorPort: input.edgeRuntimeInspectorPort, + edgeRuntimeSecrets: input.edgeRuntimeSecrets, + configDeclaredFunctions: input.configDeclaredFunctions, + configFunctions: input.configFunctions, + rawConfigFunctions: input.rawConfigFunctions, + }, + authArtifacts: input.authArtifacts, + dbUrl: legacyStartInternalDbUrl( + "postgres", + legacyServiceContainerName("db", input.projectId), + legacyStartInternalDbPassword(input.dbUrl), + ), + image: input.image, + projectRoot: input.workdir, + supabaseDir: `${input.workdir}/supabase`, + flagCwd: input.workdir, + platform: input.platform, + debug: input.debug, + networkId: input.networkId, + // Go's `start.go:1104` passes `serve.RuntimeOption{}` (its zero value) + // and an empty `envFilePath`/`nil` `noVerifyJWT`/empty `importMapPath` + // — `start` has no CLI flags of its own for any of these. + envFile: Option.none(), + importMap: Option.none(), + noVerifyJwt: Option.none(), + inspectMode: undefined, + inspectMain: false, + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts new file mode 100644 index 0000000000..b915a3d2b8 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts @@ -0,0 +1,699 @@ +/** + * GoTrue/Auth env + container spec builder — port of Go's "Start GoTrue" + * block (`apps/cli-go/internal/start/start.go:629-851`) and its + * `buildGotrueEnv`/`appendGotruePasskeyEnv`/`appendGotrueExternalProviderEnv`/ + * `formatMapForEnvConfig` helpers (`start.go:1305-1462`). Gated in Go by + * `config.auth.enabled` and `!isContainerExcluded(config.auth.image, + * excluded)` — see `legacy-service-catalog.ts`'s `gotrue` entry (`excludeKey: + * "gotrue"`, gated on `auth.enabled`). Gating and image resolution/pre-pull + * are the caller's job (a future `start.handler.ts`); this module only + * assembles the env/container spec once the caller has already decided to + * start it. + * + * {@link legacyBuildGotrueEnv} deliberately reproduces the FULL env surface + * Go's container actually receives — not just Go's narrowly-named + * `buildGotrueEnv()` return value, but every conditional env var the + * surrounding "Start GoTrue" block in `run()` appends on top of it (JWT + * signing keys, SMTP/Mailpit fallback, mailer template/notification URLs, the + * fixed-priority SMS provider switch, CAPTCHA, the six auth hooks, MFA phone + * extras, passkey/WebAuthn, external OAuth providers, Web3, OAuth server). + * Ported test: `apps/cli-go/internal/start/start_test.go:440 TestBuildGotrueEnv` + * (+`:566 TestFormatMapForEnvConfig`), see `gotrue.service.unit.test.ts`. + * + * `@supabase/config` schema gaps this module works around (all pre-existing, + * not introduced here — see each input field's own doc comment): + * - `auth.external_url` has no schema field at all, so the caller reads it + * off the raw TOML document (same pattern as the gaps below) and passes + * the resolved value in as {@link LegacyBuildGotrueEnvInput.authExternalUrl} + * — when set, it wins over the `apiUrl`-derived fallback for + * `API_EXTERNAL_URL`/`GOTRUE_JWT_ISSUER`'s default/the mailer verify URL/ + * OAuth redirect-URI fallbacks, matching `auth.GetExternalURL` + * (`pkg/config/auth.go:401-405`). `config/push/config-sync/auth.sync.ts` + * has the identical gap, unresolved — a separate command, not this PR's + * scope. + * - `auth.captcha`/`auth.passkey`/`auth.webauthn`/`auth.email.smtp`'s + * presence-and-default quirks (Go's `Config.Validate`/`Config.Load` + * treat an explicitly-omitted `enabled` differently depending on whether + * the surrounding TOML table is present at all) can't be recovered from + * the decoded `ProjectConfig` alone — the caller resolves these the same + * way `legacy-local-config-values.ts` already does (reading the raw + * TOML document) and passes the final, presence-resolved values in. + * - `auth.external` decodes as a FIXED struct of ~19 known providers, each + * always present with `enabled: false` when unconfigured — but Go's own + * `Auth.External` is a genuine `map[string]provider{}` that only ever + * contains the providers a user's `config.toml` actually mentions, and + * `appendGotrueExternalProviderEnv` iterates that real map unconditionally + * (emitting `_ENABLED=false` etc. for a configured-but-disabled provider, + * never for an unconfigured one). {@link LegacyBuildGotrueEnvInput.externalProviders} + * must therefore already be presence-filtered by the caller (only the + * providers whose `[auth.external.]` section actually exists in + * the TOML document), the same way `legacy-local-config-values.ts`'s + * `validateAuthExternalProviders` already filters for its own purposes. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import { + legacyFormatGoDuration, + legacyParseGoDuration, +} from "../../../shared/legacy-go-duration.ts"; +import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; +import type { LegacyResolvedAuthEmail } from "../../../shared/legacy-local-config-values.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "../lib/internal-db-connection.ts"; + +/** `utils.GotrueAliases[0]` (`apps/cli-go/internal/utils/config.go:38`) — also this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG`. */ +const LEGACY_GOTRUE_CONTAINER_SUFFIX = "auth"; + +/** GoTrue's fixed listen port (`start.go:825`, `ExposedPorts: nat.PortSet{"9999/tcp": {}}`) — never published to the host. */ +const LEGACY_GOTRUE_PORT = "9999"; + +/** `nginxTemplateServerPort` (`start.go:115`) — Kong's nginx template server, used for mailer template/subject URLs. */ +const LEGACY_GOTRUE_NGINX_TEMPLATE_SERVER_PORT = 8088; + +/** Go's `Config.Inbucket.AdminEmail`/`SenderName` defaults (`pkg/config/config.go:518-520`, `NewConfig()`). */ +const LEGACY_GOTRUE_DEFAULT_INBUCKET_ADMIN_EMAIL = "admin@email.com"; +const LEGACY_GOTRUE_DEFAULT_INBUCKET_SENDER_NAME = "Admin"; + +/** `supabase_auth_admin` — the fixed Postgres role GoTrue's `GOTRUE_DB_DATABASE_URL` authenticates as (`start.go:1363`). */ +const LEGACY_GOTRUE_DB_ROLE = "supabase_auth_admin"; + +/** + * RFC 7517 JWK fields Go's `JWK` struct round-trips + * (`apps/cli-go/pkg/config/auth.go:88-108`), in the exact `json:"..."` field + * declaration order — needed so {@link legacyBuildGotrueEnv}'s + * `JSON.stringify` reproduces Go's `encoding/json.Marshal` byte-for-byte + * (both languages serialize object/struct keys in declaration order). + * Structurally near-identical to `legacy/shared/legacy-go-jwt.ts`'s `LegacyJwk` + * (the only difference is `key_ops`'s mutable `string[]` there, needed for + * assignability into Node's `createPrivateKey`/`JsonWebKey` input — see that + * type's own doc comment) and a superset of `shared/auth/jwks.ts`'s `JwkLike` + * (which omits the private-key fields `d`/`p`/`q`/`dp`/`dq`/`qi`) — kept local + * rather than reusing either shared type, since neither of their existing + * callers needs the union of all seventeen fields. + */ +export interface LegacyGotrueSigningKey { + readonly kty: string; + readonly kid?: string; + readonly use?: string; + readonly key_ops?: ReadonlyArray; + readonly alg?: string; + readonly ext?: boolean; + readonly n?: string; + readonly e?: string; + readonly d?: string; + readonly p?: string; + readonly q?: string; + readonly dp?: string; + readonly dq?: string; + readonly qi?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; +} + +/** + * `Config.Auth.SigningKeys`' default single entry (`pkg/config/config.go:504-515`, + * `NewConfig()`) — used whenever `auth.signing_keys_path` is unset. Hoisted to + * `legacy-go-jwt.ts` (as `LEGACY_DEFAULT_SIGNING_KEY`) so `legacyResolveLocalJwks` + * publishes the exact same key's public form in the JWKS this default signs + * with — the two must never disagree on which key is "the" default. + */ +const LEGACY_GOTRUE_DEFAULT_SIGNING_KEY: LegacyGotrueSigningKey = LEGACY_DEFAULT_SIGNING_KEY; + +/** + * Go `PasswordRequirements.ToChar()` (`apps/cli-go/pkg/config/auth.go:34-44`). + * Values are the real Management API `password_required_characters` literals + * (the `:`-separated character classes are significant), matching the + * identical, currently un-hoisted mapping in + * `config/push/config-sync/auth.sync.ts`'s `PASSWORD_REQUIREMENTS_TO_CHAR`. + */ +const LEGACY_GOTRUE_PASSWORD_REQUIREMENTS_TO_CHAR: Record = { + letters_digits: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + lower_upper_letters_digits: "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + lower_upper_letters_digits_symbols: + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", +}; + +function legacyGotruePasswordRequirementsToChar( + value: ProjectConfig["auth"]["password_requirements"], +): string { + return LEGACY_GOTRUE_PASSWORD_REQUIREMENTS_TO_CHAR[value] ?? ""; +} + +/** + * Go's `formatMapForEnvConfig(input map[string]string, output *bytes.Buffer)` + * (`start.go:1305-1317`) — the `auth.sms.test_otp` map's `GOTRUE_SMS_TEST_OTP` + * wire format: `key:value` pairs, comma-joined, no trailing comma. Go iterates + * a `map[string]string` (unordered); this iterates `Object.entries` (insertion + * order) instead — order is not part of Go's own contract here (map iteration + * order is intentionally randomized in Go), so this doesn't need to match any + * particular Go run's order, only the `key:value,...` shape. + */ +export function legacyFormatMapForEnvConfig(input: Readonly>): string { + return Object.entries(input) + .map(([key, value]) => `${key}:${value}`) + .join(","); +} + +/** One already-resolved, presence-gated `[auth.hook.]` entry — see this module's header for why presence must be pre-resolved by the caller. */ +export interface LegacyGotrueHookInput { + readonly enabled: boolean; + readonly uri?: string; + readonly secrets?: string; +} + +/** `[auth.webauthn]`, present iff the caller's raw TOML document has that section (`Auth.Webauthn != nil` in Go). */ +export interface LegacyGotrueWebauthnInput { + readonly rpId: string; + readonly rpDisplayName: string; + readonly rpOrigins: ReadonlyArray; +} + +/** + * One already-presence-filtered `[auth.external.]` entry — see this + * module's header for why the caller must filter the whole map by TOML + * presence before calling {@link legacyBuildGotrueEnv}. + */ +export interface LegacyGotrueExternalProviderInput { + readonly enabled: boolean; + readonly clientId: string; + readonly secret?: string; + readonly url: string; + readonly redirectUri?: string; + readonly skipNonceCheck: boolean; + readonly emailOptional: boolean; +} + +export interface LegacyBuildGotrueEnvInput { + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`) — Go's `dbConfig.Host`. */ + readonly dbHost: string; + /** Go's `dbConfig.Password` (`Config.Db.Password`) — see {@link legacyStartInternalDbPassword}. */ + readonly dbPassword: string; + + /** + * `LegacyLocalConfigValues.apiUrl` (Go's resolved `Config.Api.ExternalUrl`) + * — reused, not recomputed, so `API_EXTERNAL_URL`/`GOTRUE_JWT_ISSUER`'s + * derived fallback (used when {@link authExternalUrl} is unset) never + * drifts from what `status` reports. + */ + readonly apiUrl: string; + /** + * Raw `auth.external_url` (already `SUPABASE_AUTH_EXTERNAL_URL`-overridden + * by the caller) — Go's `Config.Auth.ExternalUrl`/`AuthExternalURL()` + * (`pkg/config/config.go:543-545`, `auth.go:401-405`): an explicit value + * wins outright, otherwise Go derives `TrimRight(apiUrl, "/") + "/auth/v1"`. + * `undefined`/empty means unset — fall back to the `apiUrl` derivation. + */ + readonly authExternalUrl?: string; + /** `LegacyLocalConfigValues.jwtSecret` — reused, not recomputed. */ + readonly jwtSecret: string; + /** `config.auth.jwt_issuer`, raw (`undefined` when unset — falls back to the derived auth-external URL, matching `Config.Load`'s `if len(c.Auth.JwtIssuer) == 0`). */ + readonly jwtIssuer: string | undefined; + readonly jwtExpiry: ProjectConfig["auth"]["jwt_expiry"]; + readonly siteUrl: ProjectConfig["auth"]["site_url"]; + readonly additionalRedirectUrls: ProjectConfig["auth"]["additional_redirect_urls"]; + readonly enableSignup: ProjectConfig["auth"]["enable_signup"]; + readonly enableAnonymousSignIns: ProjectConfig["auth"]["enable_anonymous_sign_ins"]; + readonly enableRefreshTokenRotation: ProjectConfig["auth"]["enable_refresh_token_rotation"]; + readonly refreshTokenReuseInterval: ProjectConfig["auth"]["refresh_token_reuse_interval"]; + readonly enableManualLinking: ProjectConfig["auth"]["enable_manual_linking"]; + readonly minimumPasswordLength: ProjectConfig["auth"]["minimum_password_length"]; + readonly passwordRequirements: ProjectConfig["auth"]["password_requirements"]; + + /** + * `Config.Auth.SigningKeys`, already resolved (the `auth.signing_keys_path` + * file's contents via `legacy-local-config-values.ts`'s `loadSigningKeys`, + * when configured). Defaults to Go's hardcoded single ES256 key + * ({@link LEGACY_GOTRUE_DEFAULT_SIGNING_KEY}) when omitted, matching + * `NewConfig()`'s default when `signing_keys_path` is unset. + */ + readonly signingKeys?: ReadonlyArray; + + /** `config.auth.email`, everything except `smtp` (kept separate — see {@link smtp}). */ + readonly email: Omit; + /** Kong's own container name (Go's `utils.KongId`) — mailer template/subject URLs route through it. */ + readonly kongContainerName: string; + + /** + * `config.auth.email.smtp`, already presence-and-default resolved by the + * caller (Go's `Auth.Email.Smtp != nil && Auth.Email.Smtp.Enabled` — + * `@supabase/config` can't reproduce Go's "TOML table present, `enabled` + * key absent → true" default on its own, see this module's header). + * `undefined` means Go's `Smtp == nil || !Smtp.Enabled` — the mailpit + * fallback below applies instead. + */ + readonly smtp?: { + readonly host: string; + readonly port: number; + readonly user: string; + readonly pass: string; + readonly adminEmail: string; + readonly senderName?: string; + }; + /** + * `config.local_smtp` (Go's `Inbucket`), present iff `local_smtp.enabled` + * AND {@link smtp} is unset (Go's `else if utils.Config.Inbucket.Enabled`). + * `adminEmail`/`senderName` default to Go's `NewConfig()` literals when + * unset, matching `@supabase/config`'s `inbucket.ts` schema having no + * default for either (unlike Go, which always has a literal default even + * when the key is absent from `config.toml`). + */ + readonly mailpit?: { + readonly containerName: string; + readonly adminEmail?: string; + readonly senderName?: string; + }; + + readonly sms: ProjectConfig["auth"]["sms"]; + readonly sessions: ProjectConfig["auth"]["sessions"]; + readonly mfa: ProjectConfig["auth"]["mfa"]; + readonly rateLimit: ProjectConfig["auth"]["rate_limit"]; + readonly web3: ProjectConfig["auth"]["web3"]; + readonly oauthServer: ProjectConfig["auth"]["oauth_server"]; + /** + * `config.auth.hook.`, each already presence-resolved AND + * env-override-resolved by the caller (`legacyResolveAuthHooks` — + * `SUPABASE_AUTH_HOOK__ENABLED`/`_URI`/`_SECRETS`). + */ + readonly hooks: { + readonly mfaVerificationAttempt: LegacyGotrueHookInput; + readonly passwordVerificationAttempt: LegacyGotrueHookInput; + readonly customAccessToken: LegacyGotrueHookInput; + readonly sendSms: LegacyGotrueHookInput; + readonly sendEmail: LegacyGotrueHookInput; + readonly beforeUserCreated: LegacyGotrueHookInput; + }; + + /** + * `config.auth.captcha`, already presence-resolved AND env-override-resolved + * by the caller (`legacyResolveAuthCaptcha` — `SUPABASE_AUTH_CAPTCHA_ENABLED`/ + * `_PROVIDER`/`_SECRET`, `secret` decrypted like every other `Secret`-typed + * field). `@supabase/config`'s `withDecodingDefaultKey` fills in `{enabled: + * false}` even when `[auth.captcha]` is absent — see this module's header. + * `undefined` means `Auth.Captcha == nil` in Go. + */ + readonly captcha?: { + readonly enabled: boolean; + readonly provider?: string; + readonly secret?: string; + }; + + /** `[auth.passkey].enabled`, `undefined` iff the section is absent — see this module's header (not in `@supabase/config`'s schema at all). */ + readonly passkeyEnabled?: boolean; + /** `[auth.webauthn]`, `undefined` iff the section is absent — independent of {@link passkeyEnabled} (Go checks both pointers separately). */ + readonly webauthn?: LegacyGotrueWebauthnInput; + + /** Already presence-filtered by the caller — see this module's header. */ + readonly externalProviders: Readonly>; +} + +function legacyTrimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ""); +} + +/** + * Port of Go's `appendGotruePasskeyEnv` (`start.go:1427-1440`). Two + * INDEPENDENT presence gates (`Auth.Passkey != nil`, `Auth.Webauthn != nil`) + * — Go's `Config.Validate` requires `Auth.Webauthn` whenever + * `Auth.Passkey.Enabled`, but this function doesn't assume that invariant, + * matching Go's own un-assuming implementation. + */ +function legacyAppendGotruePasskeyEnv( + env: Record, + passkeyEnabled: boolean | undefined, + webauthn: LegacyGotrueWebauthnInput | undefined, +): void { + if (passkeyEnabled !== undefined) { + env["GOTRUE_PASSKEY_ENABLED"] = String(passkeyEnabled); + } + if (webauthn !== undefined) { + env["GOTRUE_WEBAUTHN_RP_ID"] = webauthn.rpId; + env["GOTRUE_WEBAUTHN_RP_DISPLAY_NAME"] = webauthn.rpDisplayName; + env["GOTRUE_WEBAUTHN_RP_ORIGINS"] = webauthn.rpOrigins.join(","); + } +} + +/** + * Port of Go's `appendGotrueExternalProviderEnv` (`start.go:1442-1462`). + * Iterates `providers` unconditionally (every entry, enabled or not) — Go's + * loop has no `if config.Enabled` gate at all, only the trailing `URL` field + * is conditional. See this module's header for why `providers` must already + * be presence-filtered by the caller. + */ +function legacyAppendGotrueExternalProviderEnv( + env: Record, + providers: Readonly>, + jwtIssuer: string, +): void { + for (const [name, provider] of Object.entries(providers)) { + const upper = name.toUpperCase(); + const redirectUri = + provider.redirectUri !== undefined && provider.redirectUri.length > 0 + ? provider.redirectUri + : `${jwtIssuer}/callback`; + env[`GOTRUE_EXTERNAL_${upper}_ENABLED`] = String(provider.enabled); + env[`GOTRUE_EXTERNAL_${upper}_CLIENT_ID`] = provider.clientId; + env[`GOTRUE_EXTERNAL_${upper}_SECRET`] = provider.secret ?? ""; + env[`GOTRUE_EXTERNAL_${upper}_SKIP_NONCE_CHECK`] = String(provider.skipNonceCheck); + env[`GOTRUE_EXTERNAL_${upper}_EMAIL_OPTIONAL`] = String(provider.emailOptional); + env[`GOTRUE_EXTERNAL_${upper}_REDIRECT_URI`] = redirectUri; + if (provider.url.length > 0) { + env[`GOTRUE_EXTERNAL_${upper}_URL`] = provider.url; + } + } +} + +/** + * `json.Marshal`-equivalent for one signing key: builds a plain object with + * keys in Go's `JWK` struct declaration order, omitting every field Go's + * `omitempty` would drop (an absent/`undefined` field, or an empty string — + * `Extractable`/`KeyOps` are pointer/slice-typed in Go, so only `undefined`, + * not a falsy value, is dropped for those two). `JSON.stringify` then + * serializes in that same insertion order, matching `encoding/json.Marshal` + * byte-for-byte. + */ +function legacyOrderGotrueSigningKey(key: LegacyGotrueSigningKey): Record { + const ordered: Record = { kty: key.kty }; + if (key.kid !== undefined && key.kid.length > 0) ordered["kid"] = key.kid; + if (key.use !== undefined && key.use.length > 0) ordered["use"] = key.use; + if (key.key_ops !== undefined) ordered["key_ops"] = key.key_ops; + if (key.alg !== undefined && key.alg.length > 0) ordered["alg"] = key.alg; + if (key.ext !== undefined) ordered["ext"] = key.ext; + if (key.n !== undefined && key.n.length > 0) ordered["n"] = key.n; + if (key.e !== undefined && key.e.length > 0) ordered["e"] = key.e; + if (key.d !== undefined && key.d.length > 0) ordered["d"] = key.d; + if (key.p !== undefined && key.p.length > 0) ordered["p"] = key.p; + if (key.q !== undefined && key.q.length > 0) ordered["q"] = key.q; + if (key.dp !== undefined && key.dp.length > 0) ordered["dp"] = key.dp; + if (key.dq !== undefined && key.dq.length > 0) ordered["dq"] = key.dq; + if (key.qi !== undefined && key.qi.length > 0) ordered["qi"] = key.qi; + if (key.crv !== undefined && key.crv.length > 0) ordered["crv"] = key.crv; + if (key.x !== undefined && key.x.length > 0) ordered["x"] = key.x; + if (key.y !== undefined && key.y.length > 0) ordered["y"] = key.y; + return ordered; +} + +/** `filepath.Ext` (Go stdlib) — the last `.`-prefixed suffix of the final path segment, or `""` if there is none. */ +function legacyFileExt(path: string): string { + const base = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1); + const dot = base.lastIndexOf("."); + return dot <= 0 ? "" : base.slice(dot); +} + +/** + * Pure GoTrue container env builder — port of Go's `buildGotrueEnv` PLUS + * every conditional env var the surrounding "Start GoTrue" block in `run()` + * appends on top of it (`start.go:629-819`). See this module's header for the + * full rationale and the `@supabase/config` schema gaps this input type works + * around. No Effect, no I/O — every field is a plain, already-resolved value. + */ +export function legacyBuildGotrueEnv(input: LegacyBuildGotrueEnvInput): Record { + const authExternalUrl = + input.authExternalUrl !== undefined && input.authExternalUrl.length > 0 + ? input.authExternalUrl + : `${legacyTrimTrailingSlashes(input.apiUrl)}/auth/v1`; + const jwtIssuer = + input.jwtIssuer !== undefined && input.jwtIssuer.length > 0 ? input.jwtIssuer : authExternalUrl; + const mailerVerifyUrl = `${legacyTrimTrailingSlashes(authExternalUrl)}/verify`; + const testOtp = + input.sms.test_otp !== undefined ? legacyFormatMapForEnvConfig(input.sms.test_otp) : ""; + + const env: Record = { + API_EXTERNAL_URL: authExternalUrl, + + GOTRUE_API_HOST: "0.0.0.0", + GOTRUE_API_PORT: LEGACY_GOTRUE_PORT, + + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_DB_DATABASE_URL: legacyStartInternalDbUrl( + LEGACY_GOTRUE_DB_ROLE, + input.dbHost, + input.dbPassword, + ), + + GOTRUE_SITE_URL: input.siteUrl, + GOTRUE_URI_ALLOW_LIST: input.additionalRedirectUrls.join(","), + GOTRUE_DISABLE_SIGNUP: String(!input.enableSignup), + + GOTRUE_JWT_ADMIN_ROLES: "service_role", + GOTRUE_JWT_AUD: "authenticated", + GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", + GOTRUE_JWT_EXP: String(input.jwtExpiry), + GOTRUE_JWT_SECRET: input.jwtSecret, + GOTRUE_JWT_ISSUER: jwtIssuer, + + GOTRUE_EXTERNAL_EMAIL_ENABLED: String(input.email.enable_signup), + GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: String(input.email.double_confirm_changes), + GOTRUE_MAILER_AUTOCONFIRM: String(!input.email.enable_confirmations), + GOTRUE_MAILER_OTP_LENGTH: String(input.email.otp_length), + GOTRUE_MAILER_OTP_EXP: String(input.email.otp_expiry), + GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED: "true", + + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: String(input.enableAnonymousSignIns), + + GOTRUE_SMTP_MAX_FREQUENCY: legacyFormatGoDuration( + legacyParseGoDuration(input.email.max_frequency), + ), + + GOTRUE_MAILER_URLPATHS_INVITE: mailerVerifyUrl, + GOTRUE_MAILER_URLPATHS_CONFIRMATION: mailerVerifyUrl, + GOTRUE_MAILER_URLPATHS_RECOVERY: mailerVerifyUrl, + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: mailerVerifyUrl, + GOTRUE_RATE_LIMIT_EMAIL_SENT: "360000", + + GOTRUE_EXTERNAL_PHONE_ENABLED: String(input.sms.enable_signup), + GOTRUE_SMS_AUTOCONFIRM: String(!input.sms.enable_confirmations), + GOTRUE_SMS_MAX_FREQUENCY: legacyFormatGoDuration( + legacyParseGoDuration(input.sms.max_frequency), + ), + GOTRUE_SMS_OTP_EXP: "6000", + GOTRUE_SMS_OTP_LENGTH: "6", + GOTRUE_SMS_TEMPLATE: input.sms.template, + GOTRUE_SMS_TEST_OTP: testOtp, + + GOTRUE_PASSWORD_MIN_LENGTH: String(input.minimumPasswordLength), + GOTRUE_PASSWORD_REQUIRED_CHARACTERS: legacyGotruePasswordRequirementsToChar( + input.passwordRequirements, + ), + GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: String(input.enableRefreshTokenRotation), + GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: String(input.refreshTokenReuseInterval), + GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: String(input.enableManualLinking), + GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: String( + input.email.secure_password_change, + ), + GOTRUE_MFA_PHONE_ENROLL_ENABLED: String(input.mfa.phone.enroll_enabled), + GOTRUE_MFA_PHONE_VERIFY_ENABLED: String(input.mfa.phone.verify_enabled), + GOTRUE_MFA_TOTP_ENROLL_ENABLED: String(input.mfa.totp.enroll_enabled), + GOTRUE_MFA_TOTP_VERIFY_ENABLED: String(input.mfa.totp.verify_enabled), + GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: String(input.mfa.web_authn.enroll_enabled), + GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: String(input.mfa.web_authn.verify_enabled), + GOTRUE_MFA_MAX_ENROLLED_FACTORS: String(input.mfa.max_enrolled_factors), + + GOTRUE_RATE_LIMIT_ANONYMOUS_USERS: String(input.rateLimit.anonymous_users), + GOTRUE_RATE_LIMIT_TOKEN_REFRESH: String(input.rateLimit.token_refresh), + GOTRUE_RATE_LIMIT_OTP: String(input.rateLimit.sign_in_sign_ups), + GOTRUE_RATE_LIMIT_VERIFY: String(input.rateLimit.token_verifications), + GOTRUE_RATE_LIMIT_SMS_SENT: String(input.rateLimit.sms_sent), + GOTRUE_RATE_LIMIT_WEB3: String(input.rateLimit.web3), + }; + + // JWT signing keys (start.go:634-640) — `json.Marshal` never fails for this + // shape, so Go's `if err == nil` guard is effectively unconditional here. + const signingKeys = input.signingKeys ?? [LEGACY_GOTRUE_DEFAULT_SIGNING_KEY]; + env["GOTRUE_JWT_KEYS"] = JSON.stringify(signingKeys.map(legacyOrderGotrueSigningKey)); + // TODO: deprecate HS256 when it's no longer supported. + // TODO: remove VALIDMETHODS after a while to avoid breaking changes. + env["GOTRUE_JWT_VALIDMETHODS"] = "HS256,RS256,ES256"; + env["GOTRUE_JWT_VALID_METHODS"] = "HS256,RS256,ES256"; + + // SMTP, else Mailpit fallback (start.go:642-659). + if (input.smtp !== undefined) { + env["GOTRUE_RATE_LIMIT_EMAIL_SENT"] = String(input.rateLimit.email_sent); + env["GOTRUE_SMTP_HOST"] = input.smtp.host; + env["GOTRUE_SMTP_PORT"] = String(input.smtp.port); + env["GOTRUE_SMTP_USER"] = input.smtp.user; + env["GOTRUE_SMTP_PASS"] = input.smtp.pass; + env["GOTRUE_SMTP_ADMIN_EMAIL"] = input.smtp.adminEmail; + env["GOTRUE_SMTP_SENDER_NAME"] = input.smtp.senderName ?? ""; + } else if (input.mailpit !== undefined) { + env["GOTRUE_SMTP_HOST"] = input.mailpit.containerName; + env["GOTRUE_SMTP_PORT"] = "1025"; + env["GOTRUE_SMTP_ADMIN_EMAIL"] = + input.mailpit.adminEmail ?? LEGACY_GOTRUE_DEFAULT_INBUCKET_ADMIN_EMAIL; + env["GOTRUE_SMTP_SENDER_NAME"] = + input.mailpit.senderName ?? LEGACY_GOTRUE_DEFAULT_INBUCKET_SENDER_NAME; + } + + // Sessions (start.go:661-666) — only emitted when the parsed duration is + // strictly positive, matching Go's `> 0` guard on the zero `time.Duration`. + if (input.sessions?.timebox !== undefined) { + const nanoseconds = legacyParseGoDuration(input.sessions.timebox); + if (nanoseconds > 0) { + env["GOTRUE_SESSIONS_TIMEBOX"] = legacyFormatGoDuration(nanoseconds); + } + } + if (input.sessions?.inactivity_timeout !== undefined) { + const nanoseconds = legacyParseGoDuration(input.sessions.inactivity_timeout); + if (nanoseconds > 0) { + env["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"] = legacyFormatGoDuration(nanoseconds); + } + } + + // Mailer template/notification URLs and subjects (start.go:668-694). + // `subject !== undefined` matches Go's `subject *string; if subject != nil` exactly: the + // caller (`legacyResolveAuthEmail`) has already recovered the "explicit empty string" vs + // "absent" distinction from the raw document, so `undefined` here means Go's nil (omit the + // env var entirely) and `""` means an explicit blank subject (still emit it). + const addMailerEnvVars = (id: string, contentPath: string, subject: string | undefined): void => { + if (contentPath.length > 0) { + env[`GOTRUE_MAILER_TEMPLATES_${id.toUpperCase()}`] = + `http://${input.kongContainerName}:${LEGACY_GOTRUE_NGINX_TEMPLATE_SERVER_PORT}/email/${id}${legacyFileExt(contentPath)}`; + } + if (subject !== undefined) { + env[`GOTRUE_MAILER_SUBJECTS_${id.toUpperCase()}`] = subject; + } + }; + for (const [id, template] of Object.entries(input.email.template)) { + addMailerEnvVars(id, template.content_path, template.subject); + } + for (const [id, notification] of Object.entries(input.email.notification)) { + if (notification.enabled) { + env[`GOTRUE_MAILER_NOTIFICATIONS_${id.toUpperCase()}_ENABLED`] = "true"; + addMailerEnvVars(`${id}_notification`, notification.content_path, notification.subject); + } + } + + // SMS provider switch (start.go:696-735) — first enabled provider wins, in + // Go's fixed priority order; a later enabled-but-lower-priority provider is + // never inspected. + if (input.sms.twilio.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "twilio"; + env["GOTRUE_SMS_TWILIO_ACCOUNT_SID"] = input.sms.twilio.account_sid; + env["GOTRUE_SMS_TWILIO_AUTH_TOKEN"] = input.sms.twilio.auth_token ?? ""; + env["GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID"] = input.sms.twilio.message_service_sid; + } else if (input.sms.twilio_verify.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "twilio_verify"; + env["GOTRUE_SMS_TWILIO_VERIFY_ACCOUNT_SID"] = input.sms.twilio_verify.account_sid ?? ""; + env["GOTRUE_SMS_TWILIO_VERIFY_AUTH_TOKEN"] = input.sms.twilio_verify.auth_token ?? ""; + env["GOTRUE_SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID"] = + input.sms.twilio_verify.message_service_sid ?? ""; + } else if (input.sms.messagebird.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "messagebird"; + env["GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY"] = input.sms.messagebird.access_key ?? ""; + env["GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR"] = input.sms.messagebird.originator ?? ""; + } else if (input.sms.textlocal.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "textlocal"; + env["GOTRUE_SMS_TEXTLOCAL_API_KEY"] = input.sms.textlocal.api_key ?? ""; + env["GOTRUE_SMS_TEXTLOCAL_SENDER"] = input.sms.textlocal.sender ?? ""; + } else if (input.sms.vonage.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "vonage"; + env["GOTRUE_SMS_VONAGE_API_KEY"] = input.sms.vonage.api_key ?? ""; + env["GOTRUE_SMS_VONAGE_API_SECRET"] = input.sms.vonage.api_secret ?? ""; + env["GOTRUE_SMS_VONAGE_FROM"] = input.sms.vonage.from ?? ""; + } + + // CAPTCHA (start.go:737-744). + if (input.captcha !== undefined) { + env["GOTRUE_SECURITY_CAPTCHA_ENABLED"] = String(input.captcha.enabled); + env["GOTRUE_SECURITY_CAPTCHA_PROVIDER"] = input.captcha.provider ?? ""; + env["GOTRUE_SECURITY_CAPTCHA_SECRET"] = input.captcha.secret ?? ""; + } + + // Auth hooks (start.go:746-793) — each independently gated on its own `enabled`. + const appendHookEnv = (envPrefix: string, hook: LegacyGotrueHookInput): void => { + if (!hook.enabled) return; + env[`GOTRUE_HOOK_${envPrefix}_ENABLED`] = "true"; + env[`GOTRUE_HOOK_${envPrefix}_URI`] = hook.uri ?? ""; + env[`GOTRUE_HOOK_${envPrefix}_SECRETS`] = hook.secrets ?? ""; + }; + appendHookEnv("MFA_VERIFICATION_ATTEMPT", input.hooks.mfaVerificationAttempt); + appendHookEnv("PASSWORD_VERIFICATION_ATTEMPT", input.hooks.passwordVerificationAttempt); + appendHookEnv("CUSTOM_ACCESS_TOKEN", input.hooks.customAccessToken); + appendHookEnv("SEND_SMS", input.hooks.sendSms); + appendHookEnv("SEND_EMAIL", input.hooks.sendEmail); + appendHookEnv("BEFORE_USER_CREATED", input.hooks.beforeUserCreated); + + // MFA phone extras (start.go:795-802) — only when phone MFA is reachable at all. + if (input.mfa.phone.enroll_enabled || input.mfa.phone.verify_enabled) { + env["GOTRUE_MFA_PHONE_TEMPLATE"] = input.mfa.phone.template; + env["GOTRUE_MFA_PHONE_OTP_LENGTH"] = String(input.mfa.phone.otp_length); + env["GOTRUE_MFA_PHONE_MAX_FREQUENCY"] = legacyFormatGoDuration( + legacyParseGoDuration(input.mfa.phone.max_frequency), + ); + } + + // Passkey/WebAuthn, then external OAuth providers (start.go:804-805). + legacyAppendGotruePasskeyEnv(env, input.passkeyEnabled, input.webauthn); + legacyAppendGotrueExternalProviderEnv(env, input.externalProviders, jwtIssuer); + + // Web3 (start.go:806-809) — always emitted, unconditionally. + env["GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED"] = String(input.web3.solana.enabled); + env["GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED"] = String(input.web3.ethereum.enabled); + + // OAuth server configuration (start.go:811-818). + if (input.oauthServer.enabled) { + env["GOTRUE_OAUTH_SERVER_ENABLED"] = String(input.oauthServer.enabled); + env["GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH"] = input.oauthServer.authorization_url_path; + env["GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION"] = String( + input.oauthServer.allow_dynamic_registration, + ); + } + + return env; +} + +export interface LegacyGotrueContainerSpecInput { + /** `container.Config.Image` — the already-resolved `config.auth.image`. Not part of the decoded `@supabase/config` schema (Go's own `Auth.Image` field is `toml:"-"`); resolution is the caller's responsibility. */ + readonly image: string; + /** Go's `Config.ProjectId`, used to derive `utils.GotrueId`/the `db` container's own name via {@link legacyServiceContainerName}. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`'s target — resolved once per `start` run, not per-container. */ + readonly networkId: string; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB password (see {@link legacyStartInternalDbPassword}). */ + readonly dbUrl: string; + /** Every value {@link legacyBuildGotrueEnv} needs, minus the two this builder derives itself (`dbHost`/`dbPassword`). */ + readonly env: Omit; +} + +/** + * Builds the `docker create` spec for the GoTrue/Auth container + * (`start.go:820-850`). No `ports` (host-published) entry — GoTrue, like + * Realtime, only ever `ExposedPorts` its port on the Docker network. + */ +export function legacyBuildGotrueContainerSpec( + input: LegacyGotrueContainerSpecInput, +): LegacyStartContainerSpec { + const dbHost = legacyServiceContainerName("db", input.projectId); + const dbPassword = legacyStartInternalDbPassword(input.dbUrl); + const env = legacyBuildGotrueEnv({ ...input.env, dbHost, dbPassword }); + + return { + image: input.image, + containerName: legacyServiceContainerName(LEGACY_GOTRUE_CONTAINER_SUFFIX, input.projectId), + env, + binds: [], + exposedPorts: [{ containerPort: LEGACY_GOTRUE_PORT }], + healthcheck: { + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + `http://127.0.0.1:${LEGACY_GOTRUE_PORT}/health`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: [LEGACY_GOTRUE_CONTAINER_SUFFIX], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts new file mode 100644 index 0000000000..a38d4401a6 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts @@ -0,0 +1,715 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildGotrueContainerSpec, + legacyBuildGotrueEnv, + legacyFormatMapForEnvConfig, + type LegacyBuildGotrueEnvInput, + type LegacyGotrueExternalProviderInput, + type LegacyGotrueHookInput, + type LegacyGotrueSigningKey, + type LegacyGotrueWebauthnInput, +} from "./gotrue.service.ts"; + +// Mirrors the fixture Go's `TestBuildGotrueEnv` builds via `config.NewConfig()` +// (`apps/cli-go/internal/start/start_test.go:440-520`), translated into this +// pure function's explicit input shape. Every field not asserted by a +// specific Go subtest below reflects `config.NewConfig()`'s own defaults. +const baseEnvInput: LegacyBuildGotrueEnvInput = { + dbHost: "db", + dbPassword: "postgres", + apiUrl: "http://127.0.0.1:54321", + jwtSecret: "test-jwt-secret", + jwtIssuer: undefined, + jwtExpiry: 3600, + siteUrl: "http://127.0.0.1:3000", + additionalRedirectUrls: ["https://127.0.0.1:3000"], + enableSignup: true, + enableAnonymousSignIns: false, + enableRefreshTokenRotation: true, + refreshTokenReuseInterval: 10, + enableManualLinking: false, + minimumPasswordLength: 6, + passwordRequirements: "", + email: { + enable_signup: true, + double_confirm_changes: true, + enable_confirmations: false, + secure_password_change: false, + max_frequency: "1s", + otp_length: 6, + otp_expiry: 3600, + template: {}, + notification: {}, + }, + kongContainerName: "test-kong", + sms: { + enable_signup: false, + enable_confirmations: false, + template: "Your code is {{ .Code }}", + max_frequency: "5s", + twilio: { enabled: false, account_sid: "", message_service_sid: "" }, + twilio_verify: { enabled: false }, + messagebird: { enabled: false }, + textlocal: { enabled: false }, + vonage: { enabled: false }, + }, + sessions: undefined, + mfa: { + totp: { enroll_enabled: false, verify_enabled: false }, + phone: { + enroll_enabled: false, + verify_enabled: false, + otp_length: 6, + template: "Your code is {{ .Code }}", + max_frequency: "5s", + }, + web_authn: { enroll_enabled: false, verify_enabled: false }, + max_enrolled_factors: 10, + }, + rateLimit: { + anonymous_users: 30, + token_refresh: 150, + sign_in_sign_ups: 30, + token_verifications: 30, + email_sent: 2, + sms_sent: 30, + web3: 30, + }, + web3: { solana: { enabled: false }, ethereum: { enabled: false } }, + oauthServer: { + enabled: false, + authorization_url_path: "/oauth/consent", + allow_dynamic_registration: false, + }, + hooks: { + mfaVerificationAttempt: { enabled: false }, + passwordVerificationAttempt: { enabled: false }, + customAccessToken: { enabled: false }, + sendSms: { enabled: false }, + sendEmail: { enabled: false }, + beforeUserCreated: { enabled: false }, + }, + externalProviders: {}, +}; + +describe("legacyBuildGotrueEnv", () => { + // Port of TestBuildGotrueEnv's 4 sub-cases (start_test.go:440-520). + describe("TestBuildGotrueEnv parity", () => { + test("uses auth scoped external url and absolute mailer verify urls", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + apiUrl: "http://127.0.0.1:54321", + jwtIssuer: undefined, + siteUrl: "http://127.0.0.1:3000", + externalProviders: { + github: { + enabled: true, + clientId: "client-id", + secret: "secret", + url: "", + skipNonceCheck: false, + emailOptional: false, + }, + }, + }); + + expect(env["API_EXTERNAL_URL"]).toBe("http://127.0.0.1:54321/auth/v1"); + expect(env["GOTRUE_JWT_ISSUER"]).toBe("http://127.0.0.1:54321/auth/v1"); + expect(env["GOTRUE_MAILER_URLPATHS_INVITE"]).toBe("http://127.0.0.1:54321/auth/v1/verify"); + expect(env["GOTRUE_MAILER_URLPATHS_CONFIRMATION"]).toBe( + "http://127.0.0.1:54321/auth/v1/verify", + ); + expect(env["GOTRUE_MAILER_URLPATHS_RECOVERY"]).toBe("http://127.0.0.1:54321/auth/v1/verify"); + expect(env["GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE"]).toBe( + "http://127.0.0.1:54321/auth/v1/verify", + ); + expect(env["GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI"]).toBe( + "http://127.0.0.1:54321/auth/v1/callback", + ); + }); + + test("honors an explicit auth.external_url override for API_EXTERNAL_URL, the JWT issuer default, the mailer verify URL, and OAuth redirects", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + apiUrl: "http://127.0.0.1:54321", + authExternalUrl: "https://auth.example.com", + jwtIssuer: undefined, + siteUrl: "http://127.0.0.1:3000", + externalProviders: { + github: { + enabled: true, + clientId: "client-id", + secret: "secret", + url: "", + skipNonceCheck: false, + emailOptional: false, + }, + }, + }); + + expect(env["API_EXTERNAL_URL"]).toBe("https://auth.example.com"); + expect(env["GOTRUE_JWT_ISSUER"]).toBe("https://auth.example.com"); + expect(env["GOTRUE_MAILER_URLPATHS_INVITE"]).toBe("https://auth.example.com/verify"); + expect(env["GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI"]).toBe("https://auth.example.com/callback"); + }); + + test("preserves explicit provider redirect override", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + apiUrl: "http://127.0.0.1:54321", + jwtIssuer: "https://issuer.example.com/auth/v1", + siteUrl: "http://127.0.0.1:3000", + externalProviders: { + azure: { + enabled: true, + clientId: "", + url: "", + redirectUri: "https://example.com/custom/callback", + skipNonceCheck: false, + emailOptional: false, + }, + }, + }); + + expect(env["API_EXTERNAL_URL"]).toBe("http://127.0.0.1:54321/auth/v1"); + expect(env["GOTRUE_JWT_ISSUER"]).toBe("https://issuer.example.com/auth/v1"); + expect(env["GOTRUE_MAILER_URLPATHS_INVITE"]).toBe("http://127.0.0.1:54321/auth/v1/verify"); + expect(env["GOTRUE_EXTERNAL_AZURE_REDIRECT_URI"]).toBe("https://example.com/custom/callback"); + }); + + test("wires passkey and webauthn settings", () => { + const webauthn: LegacyGotrueWebauthnInput = { + rpId: "localhost", + rpDisplayName: "Supabase", + rpOrigins: ["http://127.0.0.1:5173", "http://localhost:5173"], + }; + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + passkeyEnabled: true, + webauthn, + }); + + expect(env["GOTRUE_PASSKEY_ENABLED"]).toBe("true"); + expect(env["GOTRUE_WEBAUTHN_RP_ID"]).toBe("localhost"); + expect(env["GOTRUE_WEBAUTHN_RP_DISPLAY_NAME"]).toBe("Supabase"); + expect(env["GOTRUE_WEBAUTHN_RP_ORIGINS"]).toBe("http://127.0.0.1:5173,http://localhost:5173"); + }); + + test("omits passkey and webauthn env when sections are unset", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + passkeyEnabled: undefined, + webauthn: undefined, + }); + + expect(env["GOTRUE_PASSKEY_ENABLED"]).toBeUndefined(); + expect(env["GOTRUE_WEBAUTHN_RP_ID"]).toBeUndefined(); + }); + }); + + // Port of TestFormatMapForEnvConfig (start_test.go:566-612), exercised + // through GOTRUE_SMS_TEST_OTP since `formatMapForEnvConfig` is only ever + // called from inside `buildGotrueEnv`. + describe("GOTRUE_SMS_TEST_OTP / formatMapForEnvConfig parity", () => { + test("legacyFormatMapForEnvConfig produces key:value pairs with no trailing comma", () => { + expect(legacyFormatMapForEnvConfig({})).toBe(""); + expect(legacyFormatMapForEnvConfig({ "123456": "123456" })).toMatch(/^\w{6}:\w{6}$/); + expect(legacyFormatMapForEnvConfig({ "123456": "123456", "234567": "234567" })).toMatch( + /^\w{6}:\w{6},\w{6}:\w{6}$/, + ); + }); + + test("defaults GOTRUE_SMS_TEST_OTP to an empty string when auth.sms.test_otp is unset", () => { + const env = legacyBuildGotrueEnv({ ...baseEnvInput, sms: { ...baseEnvInput.sms } }); + expect(env["GOTRUE_SMS_TEST_OTP"]).toBe(""); + }); + + test("formats a single-entry auth.sms.test_otp map", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + sms: { ...baseEnvInput.sms, test_otp: { "5555555555": "123456" } }, + }); + expect(env["GOTRUE_SMS_TEST_OTP"]).toBe("5555555555:123456"); + }); + + test("formats a multi-entry auth.sms.test_otp map with commas and no trailing comma", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + sms: { + ...baseEnvInput.sms, + test_otp: { "5555555555": "123456", "4444444444": "654321" }, + }, + }); + expect(env["GOTRUE_SMS_TEST_OTP"]).not.toMatch(/,$/); + expect(env["GOTRUE_SMS_TEST_OTP"]?.split(",").sort()).toEqual( + ["5555555555:123456", "4444444444:654321"].sort(), + ); + }); + }); + + describe("external OAuth providers", () => { + test("a simple enabled provider (github) emits the full env set, with URL omitted when unset", () => { + const github: LegacyGotrueExternalProviderInput = { + enabled: true, + clientId: "gh-client-id", + secret: "gh-secret", + url: "", + skipNonceCheck: false, + emailOptional: true, + }; + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + externalProviders: { github }, + }); + + expect(env["GOTRUE_EXTERNAL_GITHUB_ENABLED"]).toBe("true"); + expect(env["GOTRUE_EXTERNAL_GITHUB_CLIENT_ID"]).toBe("gh-client-id"); + expect(env["GOTRUE_EXTERNAL_GITHUB_SECRET"]).toBe("gh-secret"); + expect(env["GOTRUE_EXTERNAL_GITHUB_SKIP_NONCE_CHECK"]).toBe("false"); + expect(env["GOTRUE_EXTERNAL_GITHUB_EMAIL_OPTIONAL"]).toBe("true"); + expect(env["GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI"]).toBe( + "http://127.0.0.1:54321/auth/v1/callback", + ); + expect(env["GOTRUE_EXTERNAL_GITHUB_URL"]).toBeUndefined(); + }); + + test("a provider with a configured base url (keycloak) emits GOTRUE_EXTERNAL__URL", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + externalProviders: { + keycloak: { + enabled: true, + clientId: "kc-client-id", + secret: "kc-secret", + url: "https://keycloak.example.com/realms/myrealm", + skipNonceCheck: true, + emailOptional: false, + }, + }, + }); + + expect(env["GOTRUE_EXTERNAL_KEYCLOAK_URL"]).toBe( + "https://keycloak.example.com/realms/myrealm", + ); + expect(env["GOTRUE_EXTERNAL_KEYCLOAK_SKIP_NONCE_CHECK"]).toBe("true"); + }); + + test("emits full env for a configured-but-disabled provider (Go has no `if config.Enabled` gate)", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + externalProviders: { + apple: { + enabled: false, + clientId: "", + url: "", + skipNonceCheck: false, + emailOptional: false, + }, + }, + }); + + expect(env["GOTRUE_EXTERNAL_APPLE_ENABLED"]).toBe("false"); + expect(env["GOTRUE_EXTERNAL_APPLE_CLIENT_ID"]).toBe(""); + expect(env["GOTRUE_EXTERNAL_APPLE_SECRET"]).toBe(""); + }); + + test("omits any env for a provider absent from the (caller-presence-filtered) input map", () => { + const env = legacyBuildGotrueEnv({ ...baseEnvInput, externalProviders: {} }); + + expect(env["GOTRUE_EXTERNAL_GITHUB_ENABLED"]).toBeUndefined(); + expect(env["GOTRUE_EXTERNAL_APPLE_ENABLED"]).toBeUndefined(); + expect(env["GOTRUE_EXTERNAL_AZURE_ENABLED"]).toBeUndefined(); + }); + }); + + describe("SMTP / Mailpit fallback", () => { + test("uses configured SMTP when present, overriding the hardcoded GOTRUE_RATE_LIMIT_EMAIL_SENT default", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + rateLimit: { ...baseEnvInput.rateLimit, email_sent: 99 }, + smtp: { + host: "smtp.example.com", + port: 587, + user: "smtp-user", + pass: "smtp-pass", + adminEmail: "admin@example.com", + senderName: "Example", + }, + }); + + expect(env["GOTRUE_SMTP_HOST"]).toBe("smtp.example.com"); + expect(env["GOTRUE_SMTP_PORT"]).toBe("587"); + expect(env["GOTRUE_SMTP_USER"]).toBe("smtp-user"); + expect(env["GOTRUE_SMTP_PASS"]).toBe("smtp-pass"); + expect(env["GOTRUE_SMTP_ADMIN_EMAIL"]).toBe("admin@example.com"); + expect(env["GOTRUE_SMTP_SENDER_NAME"]).toBe("Example"); + expect(env["GOTRUE_RATE_LIMIT_EMAIL_SENT"]).toBe("99"); + }); + + test("falls back to Mailpit when SMTP is unset and local_smtp is enabled", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + mailpit: { containerName: "supabase_inbucket_proj" }, + }); + + expect(env["GOTRUE_SMTP_HOST"]).toBe("supabase_inbucket_proj"); + expect(env["GOTRUE_SMTP_PORT"]).toBe("1025"); + expect(env["GOTRUE_SMTP_ADMIN_EMAIL"]).toBe("admin@email.com"); + expect(env["GOTRUE_SMTP_SENDER_NAME"]).toBe("Admin"); + expect(env["GOTRUE_RATE_LIMIT_EMAIL_SENT"]).toBe("360000"); + }); + + test("emits neither SMTP nor Mailpit env when both are unset", () => { + const env = legacyBuildGotrueEnv(baseEnvInput); + expect(env["GOTRUE_SMTP_HOST"]).toBeUndefined(); + expect(env["GOTRUE_RATE_LIMIT_EMAIL_SENT"]).toBe("360000"); + }); + }); + + describe("sessions", () => { + test("omits GOTRUE_SESSIONS_TIMEBOX/INACTIVITY_TIMEOUT when unset", () => { + const env = legacyBuildGotrueEnv(baseEnvInput); + expect(env["GOTRUE_SESSIONS_TIMEBOX"]).toBeUndefined(); + expect(env["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBeUndefined(); + }); + + test("reformats a configured duration into Go's canonical Duration.String() form", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + sessions: { timebox: "1h", inactivity_timeout: "90s" }, + }); + expect(env["GOTRUE_SESSIONS_TIMEBOX"]).toBe("1h0m0s"); + expect(env["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBe("1m30s"); + }); + + test("omits a configured but zero-valued duration, matching Go's `> 0` guard", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + sessions: { timebox: "0s" }, + }); + expect(env["GOTRUE_SESSIONS_TIMEBOX"]).toBeUndefined(); + }); + }); + + describe("SMS provider switch", () => { + test("twilio takes priority when multiple providers are enabled", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + sms: { + ...baseEnvInput.sms, + twilio: { + enabled: true, + account_sid: "AC123", + message_service_sid: "MG123", + auth_token: "token123", + }, + vonage: { enabled: true, from: "vonage-from", api_key: "k", api_secret: "s" }, + }, + }); + + expect(env["GOTRUE_SMS_PROVIDER"]).toBe("twilio"); + expect(env["GOTRUE_SMS_TWILIO_ACCOUNT_SID"]).toBe("AC123"); + expect(env["GOTRUE_SMS_TWILIO_AUTH_TOKEN"]).toBe("token123"); + expect(env["GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID"]).toBe("MG123"); + expect(env["GOTRUE_SMS_VONAGE_API_KEY"]).toBeUndefined(); + }); + + test("vonage is used when it is the only enabled provider", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + sms: { + ...baseEnvInput.sms, + vonage: { enabled: true, from: "vonage-from", api_key: "k", api_secret: "s" }, + }, + }); + + expect(env["GOTRUE_SMS_PROVIDER"]).toBe("vonage"); + expect(env["GOTRUE_SMS_VONAGE_FROM"]).toBe("vonage-from"); + expect(env["GOTRUE_SMS_VONAGE_API_KEY"]).toBe("k"); + expect(env["GOTRUE_SMS_VONAGE_API_SECRET"]).toBe("s"); + }); + + test("omits GOTRUE_SMS_PROVIDER when no provider is enabled", () => { + const env = legacyBuildGotrueEnv(baseEnvInput); + expect(env["GOTRUE_SMS_PROVIDER"]).toBeUndefined(); + }); + }); + + describe("CAPTCHA", () => { + test("emits CAPTCHA env when present", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + captcha: { enabled: true, provider: "hcaptcha", secret: "captcha-secret" }, + }); + expect(env["GOTRUE_SECURITY_CAPTCHA_ENABLED"]).toBe("true"); + expect(env["GOTRUE_SECURITY_CAPTCHA_PROVIDER"]).toBe("hcaptcha"); + expect(env["GOTRUE_SECURITY_CAPTCHA_SECRET"]).toBe("captcha-secret"); + }); + + test("omits CAPTCHA env when unset", () => { + const env = legacyBuildGotrueEnv(baseEnvInput); + expect(env["GOTRUE_SECURITY_CAPTCHA_ENABLED"]).toBeUndefined(); + }); + }); + + describe("hooks", () => { + test("emits ENABLED/URI/SECRETS for each enabled hook, omits disabled ones", () => { + const customAccessToken: LegacyGotrueHookInput = { + enabled: true, + uri: "pg-functions://postgres/public/custom_access_token_hook", + secrets: "hook-secret", + }; + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + hooks: { ...baseEnvInput.hooks, customAccessToken }, + }); + + expect(env["GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]).toBe("true"); + expect(env["GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI"]).toBe( + "pg-functions://postgres/public/custom_access_token_hook", + ); + expect(env["GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"]).toBe("hook-secret"); + expect(env["GOTRUE_HOOK_SEND_EMAIL_ENABLED"]).toBeUndefined(); + }); + }); + + describe("MFA phone extras", () => { + test("emits template/otp_length/max_frequency when phone enrollment is enabled", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + mfa: { + ...baseEnvInput.mfa, + phone: { ...baseEnvInput.mfa.phone, enroll_enabled: true, max_frequency: "10s" }, + }, + }); + expect(env["GOTRUE_MFA_PHONE_TEMPLATE"]).toBe("Your code is {{ .Code }}"); + expect(env["GOTRUE_MFA_PHONE_OTP_LENGTH"]).toBe("6"); + expect(env["GOTRUE_MFA_PHONE_MAX_FREQUENCY"]).toBe("10s"); + }); + + test("omits phone extras when neither enroll nor verify is enabled", () => { + const env = legacyBuildGotrueEnv(baseEnvInput); + expect(env["GOTRUE_MFA_PHONE_TEMPLATE"]).toBeUndefined(); + expect(env["GOTRUE_MFA_PHONE_MAX_FREQUENCY"]).toBeUndefined(); + }); + }); + + describe("mailer templates and notifications", () => { + test("emits a template URL and subject, using the content path's extension", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + template: { + confirmation: { + subject: "Confirm your signup", + content_path: "supabase/templates/confirm.html", + content_present: false, + }, + }, + }, + }); + expect(env["GOTRUE_MAILER_TEMPLATES_CONFIRMATION"]).toBe( + "http://test-kong:8088/email/confirmation.html", + ); + expect(env["GOTRUE_MAILER_SUBJECTS_CONFIRMATION"]).toBe("Confirm your signup"); + }); + + // Go's `emailTemplate.Subject` is `*string` (`pkg/config/auth.go:266`); `start.go:668-676` + // gates strictly on `subject != nil`, not on string length — an explicit blank subject is + // still emitted, distinct from an absent one below. + test("still emits an explicit empty subject, distinct from an absent one", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + template: { + confirmation: { + subject: "", + content_path: "supabase/templates/confirm.html", + content_present: false, + }, + }, + }, + }); + expect(env["GOTRUE_MAILER_SUBJECTS_CONFIRMATION"]).toBe(""); + }); + + test("omits the subject env var entirely when subject is absent (undefined)", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + template: { + confirmation: { + subject: undefined, + content_path: "supabase/templates/confirm.html", + content_present: false, + }, + }, + }, + }); + expect(env).not.toHaveProperty("GOTRUE_MAILER_SUBJECTS_CONFIRMATION"); + }); + + test("emits a notification's ENABLED flag and template/subject with the _notification suffix", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + notification: { + password_changed: { + enabled: true, + subject: "Your password changed", + content_path: "supabase/templates/password_changed.html", + content_present: false, + }, + }, + }, + }); + expect(env["GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED"]).toBe("true"); + expect(env["GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION"]).toBe( + "http://test-kong:8088/email/password_changed_notification.html", + ); + expect(env["GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION"]).toBe( + "Your password changed", + ); + }); + + test("omits a disabled notification's env entirely", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + notification: { + password_changed: { + enabled: false, + subject: "Your password changed", + content_path: "supabase/templates/password_changed.html", + content_present: false, + }, + }, + }, + }); + expect(env["GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED"]).toBeUndefined(); + expect(env["GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION"]).toBeUndefined(); + }); + }); + + describe("web3 and OAuth server", () => { + test("always emits both Web3 flags, regardless of value", () => { + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + web3: { solana: { enabled: true }, ethereum: { enabled: false } }, + }); + expect(env["GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED"]).toBe("true"); + expect(env["GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED"]).toBe("false"); + }); + + test("emits OAuth server env only when enabled", () => { + const disabled = legacyBuildGotrueEnv(baseEnvInput); + expect(disabled["GOTRUE_OAUTH_SERVER_ENABLED"]).toBeUndefined(); + + const enabled = legacyBuildGotrueEnv({ + ...baseEnvInput, + oauthServer: { + enabled: true, + authorization_url_path: "/oauth/consent", + allow_dynamic_registration: true, + }, + }); + expect(enabled["GOTRUE_OAUTH_SERVER_ENABLED"]).toBe("true"); + expect(enabled["GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH"]).toBe("/oauth/consent"); + expect(enabled["GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION"]).toBe("true"); + }); + }); + + describe("password requirements", () => { + test.each([ + ["", ""], + ["letters_digits", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789"], + [ + "lower_upper_letters_digits", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + ], + ] as const)("%s -> %s", (passwordRequirements, expected) => { + const env = legacyBuildGotrueEnv({ ...baseEnvInput, passwordRequirements }); + expect(env["GOTRUE_PASSWORD_REQUIRED_CHARACTERS"]).toBe(expected); + }); + }); + + describe("JWT signing keys", () => { + test("defaults to Go's hardcoded ES256 signing key when unset", () => { + const env = legacyBuildGotrueEnv(baseEnvInput); + const keys = JSON.parse(env["GOTRUE_JWT_KEYS"] as string); + expect(keys).toEqual([ + { + kty: "EC", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + use: "sig", + key_ops: ["sign", "verify"], + alg: "ES256", + ext: true, + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + d: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", + }, + ]); + expect(env["GOTRUE_JWT_VALIDMETHODS"]).toBe("HS256,RS256,ES256"); + expect(env["GOTRUE_JWT_VALID_METHODS"]).toBe("HS256,RS256,ES256"); + }); + + test("serializes a configured signing key, omitting unset fields", () => { + const rsaKey: LegacyGotrueSigningKey = { kty: "RSA", alg: "RS256", n: "modulus", e: "AQAB" }; + const env = legacyBuildGotrueEnv({ + ...baseEnvInput, + signingKeys: [rsaKey], + }); + expect(JSON.parse(env["GOTRUE_JWT_KEYS"] as string)).toEqual([ + { kty: "RSA", alg: "RS256", n: "modulus", e: "AQAB" }, + ]); + }); + }); +}); + +describe("legacyBuildGotrueContainerSpec", () => { + test("assembles the full container spec, deriving dbHost/dbPassword from projectId/dbUrl", () => { + const spec = legacyBuildGotrueContainerSpec({ + image: "supabase/gotrue:v2.180.0", + projectId: "proj", + networkId: "supabase_network_proj", + dbUrl: "postgresql://postgres:secret@127.0.0.1:54322/postgres", + env: baseEnvInput, + }); + + expect(spec.image).toBe("supabase/gotrue:v2.180.0"); + expect(spec.containerName).toBe("supabase_auth_proj"); + expect(spec.binds).toEqual([]); + expect(spec.ports).toBeUndefined(); + expect(spec.exposedPorts).toEqual([{ containerPort: "9999" }]); + expect(spec.healthcheck).toEqual({ + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:9999/health", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.networkAliases).toEqual(["auth"]); + expect(spec.labels).toEqual({}); + + // dbHost/dbPassword flow from projectId/dbUrl into the env's connection string. + expect(spec.env["GOTRUE_DB_DATABASE_URL"]).toBe( + "postgresql://supabase_auth_admin:secret@supabase_db_proj:5432/postgres", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts b/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts new file mode 100644 index 0000000000..69c9a32831 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts @@ -0,0 +1,80 @@ +/** + * Port of Go's "Start Storage ImgProxy" block + * (`apps/cli-go/internal/start/start.go:1059-1099`). + * + * Enabled gate: `isStorageEnabled && isImgProxyEnabled` (`start.go:1060`) — + * i.e. `config.storage.enabled && config.storage.image_transformation?.enabled + * && !isContainerExcluded(imgproxyImage, excluded)`, AND Storage itself must + * be enabled (ImgProxy mounts Storage's own volumes via `VolumesFrom` below, + * so it cannot meaningfully run without it). Gating the actual container + * start is the future `start.handler.ts` orchestrator's responsibility — + * see `start.services.ts`'s `imgproxy` catalog entry (`enabledGate: + * "storage.enabled && storage.image_transformation.enabled"`, `dependsOn: + * ["storage"]`) — this module only builds the container spec once called. + * The caller must pass the SAME `isImgProxyEnabled` boolean it used for this + * gating decision into `storage.service.ts`'s `LegacyStorageEnvInput. + * imageTransformationEnabled` — see that file's header. + */ + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** + * Go's `Env` literal (`start.go:1065-1075`) — entirely static, no + * `config.toml` field feeds any of these values. + */ +export function legacyBuildImgproxyEnv(): Record { + return { + IMGPROXY_BIND: ":5001", + IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", + // Reproduced verbatim: Go's own literal is `"/"`, not a boolean-looking + // value (`start.go:1068`) — not "fixed" here, matching Go exactly. + IMGPROXY_USE_ETAG: "/", + IMGPROXY_MAX_SRC_RESOLUTION: "50", + IMGPROXY_MAX_SRC_FILE_SIZE: "25000000", + IMGPROXY_MAX_ANIMATION_FRAMES: "60", + IMGPROXY_ENABLE_WEBP_DETECTION: "true", + IMGPROXY_PRESETS: "default=width:3000/height:8192", + IMGPROXY_FORMAT_QUALITY: "jpeg=80,avif=62,webp=80", + }; +} + +export interface LegacyImgproxyContainerSpecInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.Storage.ImgProxyImage`, already resolved/pulled by the caller (`image-prepull.ts`). */ + readonly image: string; +} + +/** + * Builds the `docker create` spec for the ImgProxy container + * (`start.go:1059-1099`). `volumesFrom` mounts Storage's own volumes + * (`container.HostConfig.VolumesFrom: []string{utils.StorageId}`, + * `start.go:1084`) — no `ports`/`exposedPorts`; ImgProxy is reached only via + * its Docker network alias, from Storage's own `IMGPROXY_URL` env var + * (`storage.service.ts`'s `legacyBuildStorageEnv`). + */ +export function legacyBuildImgproxyContainerSpec( + input: LegacyImgproxyContainerSpecInput, +): LegacyStartContainerSpec { + return { + image: input.image, + containerName: legacyServiceContainerName("imgproxy", input.projectId), + env: legacyBuildImgproxyEnv(), + binds: [], + volumesFrom: [legacyServiceContainerName("storage", input.projectId)], + healthcheck: { + test: ["CMD", "imgproxy", "health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + // `utils.ImgProxyAliases = []string{"imgproxy"}` (`utils/config.go:43`). + networkAliases: ["imgproxy"], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/imgproxy.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/imgproxy.service.unit.test.ts new file mode 100644 index 0000000000..4d13b0bb12 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/imgproxy.service.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildImgproxyContainerSpec, + legacyBuildImgproxyEnv, + type LegacyImgproxyContainerSpecInput, +} from "./imgproxy.service.ts"; + +describe("legacyBuildImgproxyEnv", () => { + test("matches Go's fully static Env literal, including the literal (non-boolean) IMGPROXY_USE_ETAG value", () => { + expect(legacyBuildImgproxyEnv()).toEqual({ + IMGPROXY_BIND: ":5001", + IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", + IMGPROXY_USE_ETAG: "/", + IMGPROXY_MAX_SRC_RESOLUTION: "50", + IMGPROXY_MAX_SRC_FILE_SIZE: "25000000", + IMGPROXY_MAX_ANIMATION_FRAMES: "60", + IMGPROXY_ENABLE_WEBP_DETECTION: "true", + IMGPROXY_PRESETS: "default=width:3000/height:8192", + IMGPROXY_FORMAT_QUALITY: "jpeg=80,avif=62,webp=80", + }); + }); +}); + +describe("legacyBuildImgproxyContainerSpec", () => { + const input: LegacyImgproxyContainerSpecInput = { + projectId: "proj", + networkId: "supabase_network_proj", + image: "supabase/imgproxy:v3", + }; + + test("derives its own container name and mounts Storage's volumes via VolumesFrom", () => { + const spec = legacyBuildImgproxyContainerSpec(input); + expect(spec.containerName).toBe("supabase_imgproxy_proj"); + expect(spec.volumesFrom).toEqual(["supabase_storage_proj"]); + expect(spec.binds).toEqual([]); + }); + + test("has no ports/exposedPorts — reached only via its network alias", () => { + const spec = legacyBuildImgproxyContainerSpec(input); + expect(spec.ports).toBeUndefined(); + expect(spec.exposedPorts).toBeUndefined(); + }); + + test("builds the imgproxy-native healthcheck", () => { + const spec = legacyBuildImgproxyContainerSpec(input); + expect(spec.healthcheck).toEqual({ + test: ["CMD", "imgproxy", "health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + }); + + test("network alias is 'imgproxy'", () => { + const spec = legacyBuildImgproxyContainerSpec(input); + expect(spec.networkAliases).toEqual(["imgproxy"]); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + }); + + test("derives the storage volume-source name from a different projectId", () => { + const spec = legacyBuildImgproxyContainerSpec({ ...input, projectId: "other" }); + expect(spec.containerName).toBe("supabase_imgproxy_other"); + expect(spec.volumesFrom).toEqual(["supabase_storage_other"]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/kong.service.ts b/apps/cli/src/legacy/commands/start/services/kong.service.ts new file mode 100644 index 0000000000..f36cdd93d1 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/kong.service.ts @@ -0,0 +1,341 @@ +/** + * Kong container spec builder — port of Go's "Start Kong" block + * (`apps/cli-go/internal/start/start.go:486-627`), gated on + * `!isContainerExcluded(config.api.kong_image, excluded)` (Kong has no + * `enabled` flag of its own — it is the stack's mandatory gateway) — see + * `legacy-service-catalog.ts`'s `kong` entry (`excludeKey: "kong"`). Gating and + * image resolution/pre-pull are the caller's job (a future `start.handler.ts`); + * this module only assembles the container spec once the caller has already + * decided to start it, matching `docker-create-args.ts`'s "image already + * resolved/pulled" contract. + * + * How Go injects `kong.yml`/`custom_nginx.template`/the TLS cert+key into the + * container (`start.go:588-601`): a single `sh -c` entrypoint script with + * FOUR chained `cat <<'EOF' > && \` heredocs (joined by shell + * line-continuation into one logical command line), all four bodies — + * including `kong.yml`'s embedded service-role-key-derived bearer/query + * tokens and the TLS private key — landing directly in the container's own + * `Cmd`. Safe for Go: it builds `container.Config` structs and calls + * `Docker.ContainerCreate` over the Engine API directly, so that `Cmd` string + * never becomes a subprocess's own argv. THIS PORT SHELLS OUT to a real + * `docker create`, so it deliberately diverges here (CWE-214/522, `ps aux`/ + * `/proc//cmdline`): `kong.yml` (the service-role key) and the TLS + * cert/key (the highest-value secret — a private key) travel via + * {@link LegacyStartContainerSpec.secretFiles} instead — a HOST temp file, + * mode `0644` (world-readable — Kong's image runs its process as uid 100 + * `kong`, a non-root user, and a Linux/Podman bind mount preserves the host + * file's mode verbatim, so `0600` would make it unreadable in-container; see + * `legacyStageStartSecretFiles`'s doc comment), bind-mounted read-only at the + * exact fixed paths + * `KONG_DECLARATIVE_CONFIG`/`KONG_SSL_CERT`/`KONG_SSL_CERT_KEY` already + * reference — and never appear in this process's own argv. Only + * `custom_nginx.template`, which carries no secret content, still travels via + * the heredoc entrypoint script, matching Go exactly. + * + * The TLS cert/key `secretFiles` entries are still ALWAYS present — never a + * conditional bind — for the same reason Go always wrote them: + * `KONG_SSL_CERT`/`KONG_SSL_CERT_KEY` reference fixed in-container paths + * unconditionally. Their content is never empty either: Go's `NewConfig` + * seeds `Api.Tls.{CertContent,KeyContent}` with the embedded default + * localhost cert/key (`pkg/config/config.go:452-455`), and only overwrites + * them from disk when TLS is enabled AND both `cert_path`/`key_path` are + * configured — see {@link LegacyKongContainerSpecInput.tlsCertContent}'s doc + * comment. {@link legacyBuildKongEntrypointScript} + * reproduces the remaining `custom_nginx.template` heredoc + exec line + * byte-for-byte; see its doc comment for the exact shell mechanics. + * + * Kong mints no JWTs of its own: `BearerToken`/`QueryToken` (`start.go:501-521`) + * are Kong `request-transformer`/lua expression STRINGS built by + * `fmt.Sprintf` from the four already-generated API keys + * (`utils.Config.Auth.{SecretKey,ServiceRoleKey,PublishableKey,AnonKey}.Value` + * — see `legacy-local-config-values.ts`'s `LegacyLocalConfigValues`, which + * already resolves all four). {@link legacyBuildKongBearerToken}/ + * {@link legacyBuildKongQueryToken} reproduce those two `fmt.Sprintf` calls + * exactly; nothing in this module calls `legacyGenerateGoJwt` itself. + */ + +import * as nodePath from "node:path"; + +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; +import { legacyRenderStartKongYml } from "../lib/template-render.ts"; +import { LEGACY_START_CUSTOM_NGINX_TEMPLATE } from "../templates/custom_nginx.template.ts"; + +/** `utils.KongAliases` (`apps/cli-go/internal/utils/config.go:37`) — a fixed, non-configurable constant. */ +const LEGACY_KONG_NETWORK_ALIASES = ["kong", "api.supabase.internal"]; + +/** `nginxEmailTemplateDir` (`start.go:114`) — the fixed in-container directory email template mounts land in. */ +const LEGACY_KONG_NGINX_EMAIL_TEMPLATE_DIR = "/home/kong/templates/email"; + +/** `nginxTemplateServerPort` (`start.go:115`) — the fixed port `custom_nginx.template`'s `email_templates` server listens on. */ +const LEGACY_KONG_NGINX_TEMPLATE_SERVER_PORT = 8088; + +export interface LegacyKongApiKeys { + /** `Config.Auth.SecretKey.Value`. */ + readonly secretKey: string; + /** `Config.Auth.ServiceRoleKey.Value`. */ + readonly serviceRoleKey: string; + /** `Config.Auth.PublishableKey.Value`. */ + readonly publishableKey: string; + /** `Config.Auth.AnonKey.Value`. */ + readonly anonKey: string; +} + +/** + * Go's `BearerToken` (`start.go:501-514`): a Kong `request-transformer` lua + * expression, NOT a JWT — forwards a caller's own `Bearer sb_...` Authorization + * header verbatim, otherwise maps a matching `apikey` header to the + * corresponding `Bearer ` value, falling back to echoing `apikey` as-is. + * Reproduces the `fmt.Sprintf` call exactly, including argument order + * (secretKey, serviceRoleKey, publishableKey, anonKey). + */ +export function legacyBuildKongBearerToken(apiKeys: LegacyKongApiKeys): string { + return ( + `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) ` + + `or (headers.apikey == '${apiKeys.secretKey}' and 'Bearer ${apiKeys.serviceRoleKey}') ` + + `or (headers.apikey == '${apiKeys.publishableKey}' and 'Bearer ${apiKeys.anonKey}') ` + + `or headers.apikey)` + ); +} + +/** + * Go's `QueryToken` (`start.go:515-521`): the same mapping as + * {@link legacyBuildKongBearerToken}, applied to the `apikey` query parameter + * instead of a header, and without the `Bearer sb_...` passthrough branch + * (there is no equivalent "already a query-string bearer" case). + */ +export function legacyBuildKongQueryToken(apiKeys: LegacyKongApiKeys): string { + return ( + `$((query_params.apikey == '${apiKeys.secretKey}' and '${apiKeys.serviceRoleKey}') ` + + `or (query_params.apikey == '${apiKeys.publishableKey}' and '${apiKeys.anonKey}') ` + + `or query_params.apikey)` + ); +} + +/** + * Go's `envOrDefault("KONG_NGINX_WORKER_PROCESSES", "1")` (`start.go:583`, + * helper at `start.go:1464-1471`): the operator's own shell value wins when + * set (e.g. `KONG_NGINX_WORKER_PROCESSES=auto` for one worker per CPU core), + * otherwise Go's default of a single worker to minimize local-stack memory + * usage (Ref: supabase/cli#1271). By the time Go's `os.LookupEnv` runs here, + * `Config.Load` has already merged any project dotenv file into the real + * process env (`loadEnvIfExists`/`godotenv.Load`) — `projectEnvValues` is + * this port's equivalent merged (dotenv + ambient shell, ambient-wins) view, + * so a `KONG_NGINX_WORKER_PROCESSES` set only in a project dotenv file (not + * the ambient shell) is honored too, matching Storage's identical + * `VECTOR_*`-env handling (`storage.service.ts`). Kept separate from + * {@link legacyBuildKongContainerSpec} (which stays a pure function of + * already-resolved values, matching every other `start`-service builder) so + * this one ambient-env read is independently testable and the builder itself + * never touches `process.env`. + */ +export function legacyResolveKongNginxWorkerProcesses( + projectEnvValues: Readonly> | undefined = undefined, +): string { + return legacyEnvOrDefault("KONG_NGINX_WORKER_PROCESSES", "1", projectEnvValues); +} + +export interface LegacyKongEmailTemplateMount { + /** + * Go's `mountEmailTemplates(id, contentPath string)` `id` parameter + * (`start.go:527-542`): the raw `config.auth.email.template` key for a + * template mount, or `_notification` for an enabled + * `config.auth.email.notification` entry (`start.go:551-557`) — the caller + * is responsible for that suffixing and for filtering notifications down to + * `enabled` ones; this module only reproduces the per-mount path derivation. + */ + readonly id: string; + /** `tmpl.ContentPath` — empty means "not configured", matching Go's `len(contentPath) == 0` early return (no bind emitted). */ + readonly contentPath: string; +} + +/** + * Go's `mountEmailTemplates` closure (`start.go:527-542`): resolves + * `contentPath` to an absolute HOST path via `filepath.Abs` (relative to the + * process's own working directory — NOT `/supabase`, unlike + * `legacyResolveEmailTemplateContentPath`'s existence-check base in + * `legacy-config-validate.ts`, a genuinely different Go call site with a + * different base), joins it onto the fixed in-container email-template + * directory as `` (`path.Join`, POSIX — the container is + * always Linux regardless of the host OS, hence `nodePath.posix.join`, not the + * platform-dependent `nodePath.join`), and formats the `rw` bind. Returns + * `undefined` for an empty `contentPath`, matching Go's no-op early return + * (no bind appended). + */ +export function legacyBuildKongEmailTemplateBind( + mount: LegacyKongEmailTemplateMount, + workdir: string, +): string | undefined { + if (mount.contentPath.length === 0) return undefined; + const hostPath = nodePath.isAbsolute(mount.contentPath) + ? mount.contentPath + : nodePath.resolve(workdir, mount.contentPath); + const dockerPath = nodePath.posix.join( + LEGACY_KONG_NGINX_EMAIL_TEMPLATE_DIR, + `${mount.id}${nodePath.extname(hostPath)}`, + ); + return `${hostPath}:${dockerPath}:rw`; +} + +const LEGACY_KONG_ENTRYPOINT_HEAD = + "cat <<'EOF' > /home/kong/custom_nginx.template && \\\n" + + "./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template\n"; + +/** + * Reproduces the surviving (non-secret) half of Go's exact string + * concatenation for the Kong entrypoint (`start.go:588-601`): only the + * `custom_nginx.template` heredoc and the final `docker-entrypoint.sh` exec + * line — `LEGACY_KONG_ENTRYPOINT_HEAD + nginxTemplate + "\nEOF\n"`. The other + * three heredocs Go's version chains ahead of this one (`kong.yml`, the TLS + * cert, the TLS key) no longer travel through this script at all — see this + * module's header comment for why (`secretFiles`, CWE-214/522). + */ +export function legacyBuildKongEntrypointScript(nginxTemplate: string): string { + return LEGACY_KONG_ENTRYPOINT_HEAD + nginxTemplate + "\nEOF\n"; +} + +export interface LegacyKongContainerSpecInput { + /** `config.api.kong_image`, already resolved/pulled by the caller. */ + readonly image: string; + /** `legacyServiceContainerName("kong", projectId)` — Go's `utils.KongId`. */ + readonly containerName: string; + /** Go's `utils.NetId` — the shared Docker network every `start` container joins. */ + readonly networkId: string; + /** `config.hostname`, post-override — the `kongConfig.ApiHost` template field (currently unreferenced by `kong.yml`'s body, but still required to match Go's struct exactly). */ + readonly apiHost: string; + /** + * `config.api.port`, post-`SUPABASE_API_PORT`-override — used for the + * `kongConfig.ApiPort` template field, `KONG_PORT_MAPS`, and (alongside + * {@link apiTlsEnabled}) the published host port. + */ + readonly apiPort: number; + /** `config.api.tls.enabled`, post-override — selects the published container port (`8443` vs `8000`, `start.go:560-563`). */ + readonly apiTlsEnabled: boolean; + /** + * `Config.Api.Tls.CertContent` as a string. NOT empty-by-default: Go's + * `NewConfig` seeds this with the embedded default cert + * (`pkg/config/config.go:452-455`, `LEGACY_KONG_LOCAL_TLS_CERT`) and only + * `Validate` overwrites it from `api.tls.cert_path` when TLS is enabled AND + * both `cert_path`/`key_path` are configured — the caller must pass the + * embedded default here otherwise, matching Kong's own unconditional write + * of this field to `/home/kong/localhost.crt` (`start.go:585-601`). + */ + readonly tlsCertContent: string; + /** `Config.Api.Tls.KeyContent` as a string — see {@link tlsCertContent} for the same embedded-default requirement. */ + readonly tlsKeyContent: string; + /** The four already-generated API keys `BearerToken`/`QueryToken` are built from — see {@link legacyBuildKongBearerToken}/{@link legacyBuildKongQueryToken}. */ + readonly apiKeys: LegacyKongApiKeys; + /** Go's `utils.GotrueId` — GoTrue's own container name. */ + readonly gotrueId: string; + /** Go's `utils.RestId` — PostgREST's own container name. */ + readonly restId: string; + /** + * Go's `Config.Realtime.TenantId` (`start.go:492`) — NOT Realtime's + * container name/id. Realtime is reachable under this same value because + * it is ALSO Realtime's own network alias (`utils.RealtimeAliases = + * ["realtime", Config.Realtime.TenantId]`), so `kong.yml`'s `url: + * http://{{ .RealtimeId }}:4000/...` resolves via that alias, not via + * `legacyServiceContainerName("realtime", projectId)`. + */ + readonly realtimeTenantId: string; + /** Go's `utils.StorageId` — Storage's own container name. */ + readonly storageId: string; + /** Go's `utils.StudioId` — Studio's own container name. */ + readonly studioId: string; + /** Go's `utils.PgmetaId` — pg-meta's own container name. */ + readonly pgmetaId: string; + /** Go's `utils.EdgeRuntimeId` — Edge Runtime's own container name. */ + readonly edgeRuntimeId: string; + /** Go's `utils.LogflareId` — Logflare's own container name. */ + readonly logflareId: string; + /** Go's `utils.PoolerId` — Supavisor's own container name. */ + readonly poolerId: string; + /** + * `envOrDefault("KONG_NGINX_WORKER_PROCESSES", "1")` — already resolved by + * the caller via {@link legacyResolveKongNginxWorkerProcesses}, keeping + * this builder a pure function of its `input`. + */ + readonly nginxWorkerProcesses: string; + /** + * Go's `workdir` (`os.Getwd()` in `run()`, `start.go:308`) — used to + * resolve any relative {@link emailTemplateMounts} `contentPath` to an + * absolute host path (`filepath.Abs`, `start.go:531-538`). + */ + readonly workdir: string; + /** + * Every `config.auth.email.template.*`/enabled `config.auth.email.notification.*` + * entry the caller has already gathered (`start.go:544-558`) — see + * {@link LegacyKongEmailTemplateMount}'s doc comment for the notification + * `id` suffixing/filtering the caller owns. Defaults to `[]` (no email + * template mounts). + */ + readonly emailTemplateMounts?: ReadonlyArray; +} + +/** + * Assembles Kong's {@link LegacyStartContainerSpec}. Pure — no Effect or + * ambient I/O — matching every other `start`-service builder in this + * directory. + */ +export function legacyBuildKongContainerSpec( + input: LegacyKongContainerSpecInput, +): LegacyStartContainerSpec { + const kongYml = legacyRenderStartKongYml({ + gotrueId: input.gotrueId, + restId: input.restId, + realtimeId: input.realtimeTenantId, + storageId: input.storageId, + studioId: input.studioId, + pgmetaId: input.pgmetaId, + edgeRuntimeId: input.edgeRuntimeId, + logflareId: input.logflareId, + poolerId: input.poolerId, + apiHost: input.apiHost, + apiPort: input.apiPort, + bearerToken: legacyBuildKongBearerToken(input.apiKeys), + queryToken: legacyBuildKongQueryToken(input.apiKeys), + }); + + const binds = (input.emailTemplateMounts ?? []) + .map((mount) => legacyBuildKongEmailTemplateBind(mount, input.workdir)) + .filter((bind): bind is string => bind !== undefined); + + const dockerPort = input.apiTlsEnabled ? 8443 : 8000; + + return { + image: input.image, + containerName: input.containerName, + env: { + KONG_DATABASE: "off", + KONG_DECLARATIVE_CONFIG: "/home/kong/kong.yml", + // Ref: https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: "LAST,A,CNAME", + KONG_PLUGINS: "request-transformer,cors", + KONG_PORT_MAPS: `${input.apiPort}:8000`, + // Ref: https://github.com/Kong/kong/issues/3974#issuecomment-482105126 + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: "160k", + KONG_NGINX_PROXY_PROXY_BUFFERS: "64 160k", + KONG_NGINX_WORKER_PROCESSES: input.nginxWorkerProcesses, + KONG_SSL_CERT: "/home/kong/localhost.crt", + KONG_SSL_CERT_KEY: "/home/kong/localhost.key", + }, + entrypoint: "sh", + cmd: ["-c", legacyBuildKongEntrypointScript(LEGACY_START_CUSTOM_NGINX_TEMPLATE)], + secretFiles: [ + { containerPath: "/home/kong/kong.yml", content: kongYml }, + { containerPath: "/home/kong/localhost.crt", content: input.tlsCertContent }, + { containerPath: "/home/kong/localhost.key", content: input.tlsKeyContent }, + ], + binds, + ports: [{ hostPort: String(input.apiPort), containerPort: String(dockerPort) }], + exposedPorts: [ + { containerPort: "8000" }, + { containerPort: "8443" }, + { containerPort: String(LEGACY_KONG_NGINX_TEMPLATE_SERVER_PORT) }, + ], + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: LEGACY_KONG_NETWORK_ALIASES, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts new file mode 100644 index 0000000000..47bc0c9830 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildKongBearerToken, + legacyBuildKongContainerSpec, + legacyBuildKongEmailTemplateBind, + legacyBuildKongEntrypointScript, + legacyBuildKongQueryToken, + legacyResolveKongNginxWorkerProcesses, + type LegacyKongApiKeys, + type LegacyKongContainerSpecInput, +} from "./kong.service.ts"; + +const apiKeys: LegacyKongApiKeys = { + secretKey: "sb_secret_abc", + serviceRoleKey: "service-role-jwt", + publishableKey: "sb_publishable_abc", + anonKey: "anon-jwt", +}; + +describe("legacyBuildKongBearerToken", () => { + test("builds the exact lua request-transformer expression (start.go:501-514)", () => { + expect(legacyBuildKongBearerToken(apiKeys)).toBe( + "$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) " + + "or (headers.apikey == 'sb_secret_abc' and 'Bearer service-role-jwt') " + + "or (headers.apikey == 'sb_publishable_abc' and 'Bearer anon-jwt') " + + "or headers.apikey)", + ); + }); +}); + +describe("legacyBuildKongQueryToken", () => { + test("builds the exact lua query-param expression (start.go:515-521)", () => { + expect(legacyBuildKongQueryToken(apiKeys)).toBe( + "$((query_params.apikey == 'sb_secret_abc' and 'service-role-jwt') " + + "or (query_params.apikey == 'sb_publishable_abc' and 'anon-jwt') " + + "or query_params.apikey)", + ); + }); +}); + +describe("legacyResolveKongNginxWorkerProcesses", () => { + test('defaults to "1" when unset (start.go:1466-1471)', () => { + expect(legacyResolveKongNginxWorkerProcesses(undefined)).toBe("1"); + }); + + test("uses a project dotenv-only value, matching Go's post-Load os.LookupEnv", () => { + expect(legacyResolveKongNginxWorkerProcesses({ KONG_NGINX_WORKER_PROCESSES: "auto" })).toBe( + "auto", + ); + }); +}); + +describe("legacyBuildKongEmailTemplateBind", () => { + test("returns undefined for an empty contentPath (start.go:528-530)", () => { + expect( + legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "" }, "/work"), + ).toBeUndefined(); + }); + + test("resolves a relative contentPath against workdir (start.go:531-538)", () => { + expect( + legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "invite.html" }, "/work"), + ).toBe("/work/invite.html:/home/kong/templates/email/invite.html:rw"); + }); + + test("leaves an absolute contentPath untouched", () => { + expect( + legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "/abs/invite.html" }, "/work"), + ).toBe("/abs/invite.html:/home/kong/templates/email/invite.html:rw"); + }); + + test("drops the extension when hostPath has none", () => { + expect( + legacyBuildKongEmailTemplateBind( + { id: "invite_notification", contentPath: "invite" }, + "/work", + ), + ).toBe("/work/invite:/home/kong/templates/email/invite_notification:rw"); + }); +}); + +describe("legacyBuildKongEntrypointScript", () => { + test("writes only the custom_nginx.template heredoc, then execs docker-entrypoint.sh (start.go:588-601, minus the secretFiles-carried heredocs)", () => { + const script = legacyBuildKongEntrypointScript("NGINX_TEMPLATE"); + expect(script).toBe( + "cat <<'EOF' > /home/kong/custom_nginx.template && \\\n" + + "./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template\n" + + "NGINX_TEMPLATE\nEOF\n", + ); + }); + + test("no longer references kong.yml or the TLS cert/key paths at all", () => { + const script = legacyBuildKongEntrypointScript("NGINX_TEMPLATE"); + expect(script).not.toContain("kong.yml"); + expect(script).not.toContain("localhost.crt"); + expect(script).not.toContain("localhost.key"); + }); +}); + +const base: LegacyKongContainerSpecInput = { + image: "supabase/kong:3.0.0", + containerName: "supabase_kong_proj", + networkId: "supabase_network_proj", + apiHost: "localhost", + apiPort: 54321, + apiTlsEnabled: false, + tlsCertContent: "", + tlsKeyContent: "", + apiKeys, + gotrueId: "supabase_auth_proj", + restId: "supabase_rest_proj", + realtimeTenantId: "realtime-dev", + storageId: "supabase_storage_proj", + studioId: "supabase_studio_proj", + pgmetaId: "supabase_pg_meta_proj", + edgeRuntimeId: "supabase_edge_runtime_proj", + logflareId: "supabase_analytics_proj", + poolerId: "supabase_pooler_proj", + nginxWorkerProcesses: "1", + workdir: "/work", +}; + +describe("legacyBuildKongContainerSpec", () => { + test("builds identity, entrypoint, restart policy, and network aliases (start.go:564-627)", () => { + const spec = legacyBuildKongContainerSpec(base); + expect(spec.image).toBe("supabase/kong:3.0.0"); + expect(spec.containerName).toBe("supabase_kong_proj"); + expect(spec.entrypoint).toBe("sh"); + expect(spec.cmd?.[0]).toBe("-c"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.networkAliases).toEqual(["kong", "api.supabase.internal"]); + expect(spec.labels).toEqual({}); + expect(spec.healthcheck).toBeUndefined(); + }); + + test("emits the fixed KONG_* env vars, including the resolved worker-process count (start.go:568-587)", () => { + const spec = legacyBuildKongContainerSpec(base); + expect(spec.env).toEqual({ + KONG_DATABASE: "off", + KONG_DECLARATIVE_CONFIG: "/home/kong/kong.yml", + KONG_DNS_ORDER: "LAST,A,CNAME", + KONG_PLUGINS: "request-transformer,cors", + KONG_PORT_MAPS: "54321:8000", + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: "160k", + KONG_NGINX_PROXY_PROXY_BUFFERS: "64 160k", + KONG_NGINX_WORKER_PROCESSES: "1", + KONG_SSL_CERT: "/home/kong/localhost.crt", + KONG_SSL_CERT_KEY: "/home/kong/localhost.key", + }); + }); + + test("publishes 8000 to the host and exposes 8000/8443/8088 when TLS is disabled (start.go:560-563,602-612)", () => { + const spec = legacyBuildKongContainerSpec({ ...base, apiTlsEnabled: false }); + expect(spec.ports).toEqual([{ hostPort: "54321", containerPort: "8000" }]); + expect(spec.exposedPorts).toEqual([ + { containerPort: "8000" }, + { containerPort: "8443" }, + { containerPort: "8088" }, + ]); + }); + + test("publishes 8443 to the host when TLS is enabled, exposed ports unchanged", () => { + const spec = legacyBuildKongContainerSpec({ ...base, apiTlsEnabled: true }); + expect(spec.ports).toEqual([{ hostPort: "54321", containerPort: "8443" }]); + expect(spec.exposedPorts).toEqual([ + { containerPort: "8000" }, + { containerPort: "8443" }, + { containerPort: "8088" }, + ]); + }); + + test("renders kong.yml using Config.Realtime.TenantId, not Realtime's container name (start.go:492)", () => { + const spec = legacyBuildKongContainerSpec(base); + const kongYml = spec.secretFiles?.find( + (f) => f.containerPath === "/home/kong/kong.yml", + )?.content; + expect(kongYml).toContain("http://realtime-dev:4000/socket"); + expect(kongYml).not.toContain("supabase_realtime_proj"); + }); + + test("embeds the bearer/query token lua expressions into the rendered kong.yml", () => { + const spec = legacyBuildKongContainerSpec(base); + const kongYml = spec.secretFiles?.find( + (f) => f.containerPath === "/home/kong/kong.yml", + )?.content; + expect(kongYml).toContain(legacyBuildKongBearerToken(apiKeys)); + expect(kongYml).toContain(legacyBuildKongQueryToken(apiKeys)); + }); + + test("has no email template binds by default", () => { + const spec = legacyBuildKongContainerSpec(base); + expect(spec.binds).toEqual([]); + }); + + test("mounts every resolved email template bind (start.go:544-558)", () => { + const spec = legacyBuildKongContainerSpec({ + ...base, + emailTemplateMounts: [ + { id: "invite", contentPath: "invite.html" }, + { id: "confirmation_notification", contentPath: "" }, + { id: "recovery_notification", contentPath: "/abs/recovery.html" }, + ], + }); + expect(spec.binds).toEqual([ + "/work/invite.html:/home/kong/templates/email/invite.html:rw", + "/abs/recovery.html:/home/kong/templates/email/recovery_notification.html:rw", + ]); + }); + + test("carries kong.yml and the TLS cert/key as secretFiles at the exact paths KONG_DECLARATIVE_CONFIG/KONG_SSL_CERT/KONG_SSL_CERT_KEY reference, never in cmd (CWE-214/522)", () => { + const spec = legacyBuildKongContainerSpec({ + ...base, + tlsCertContent: "-----BEGIN CERTIFICATE-----", + tlsKeyContent: "-----BEGIN PRIVATE KEY-----", + }); + expect(spec.secretFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ containerPath: "/home/kong/localhost.crt" }), + expect.objectContaining({ containerPath: "/home/kong/localhost.key" }), + ]), + ); + const cert = spec.secretFiles?.find( + (f) => f.containerPath === "/home/kong/localhost.crt", + )?.content; + const key = spec.secretFiles?.find( + (f) => f.containerPath === "/home/kong/localhost.key", + )?.content; + expect(cert).toBe("-----BEGIN CERTIFICATE-----"); + expect(key).toBe("-----BEGIN PRIVATE KEY-----"); + + const script = String(spec.cmd?.[1]); + expect(script).not.toContain("-----BEGIN CERTIFICATE-----"); + expect(script).not.toContain("-----BEGIN PRIVATE KEY-----"); + expect(script).not.toContain("kong.yml"); + }); + + test("still carries (empty-content) TLS cert/key secretFiles entries when TLS is unconfigured — an unconditional bind, matching Go's always-written empty files", () => { + const spec = legacyBuildKongContainerSpec(base); + const cert = spec.secretFiles?.find((f) => f.containerPath === "/home/kong/localhost.crt"); + const key = spec.secretFiles?.find((f) => f.containerPath === "/home/kong/localhost.key"); + expect(cert?.content).toBe(""); + expect(key?.content).toBe(""); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts new file mode 100644 index 0000000000..3b61d6a9d7 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -0,0 +1,159 @@ +/** + * Port of Go's "Start Logflare" block (`apps/cli-go/internal/start/start.go: + * 313-394`), gated on `config.analytics.enabled` — the gate itself is + * `start.handler.ts`'s job (a later task), not this module's; this file only + * builds the `docker create` spec. + * + * Two things make this the fullest worked example among the three real + * services ported in this file group: + * + * - A custom `Entrypoint`/`Cmd` pair that writes and runs its own `run.sh`, + * because the image's own entrypoint conflicts with the healthcheck due to + * a 15-second sleep + * (https://github.com/Logflare/logflare/blob/staging/run.sh#L35). + * - A `config.analytics.backend` branch (`postgres` vs `bigquery`, + * `start.go:333-348`) that appends different env vars (and, for BigQuery + * only, a bind mount for the GCP service-account JSON). + */ + +import { join } from "node:path"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** `utils.LogflareAliases[0]` (`apps/cli-go/internal/utils/config.go:47`) — also this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG`. */ +const LEGACY_LOGFLARE_CONTAINER_SUFFIX = "analytics"; + +/** + * `utils.SUPERUSER_ROLE` (`apps/cli-go/internal/utils/connect.go:338`) — the DB + * user Logflare's own Ecto connection authenticates as. Distinct from + * {@link LegacyLogflareContainerSpecInput.dbUser} (`dbConfig.User = "postgres"` + * in Go), which is used only for the Postgres-backend `POSTGRES_BACKEND_URL` + * below (`start.go:344-347`) — Go really does use two different DB users for + * two different env vars in this one block. + */ +const LEGACY_LOGFLARE_DB_USERNAME = "supabase_admin"; + +/** + * `Config.Analytics.ApiKey`'s only possible value + * (`apps/cli-go/pkg/config/config.go:307,529`). The field is `toml:"-"` — + * excluded from `config.toml` unmarshalling entirely — so this can never + * actually vary; hardcoding it here (rather than threading it through as an + * input) mirrors that Go compile-time constant exactly. + */ +const LEGACY_LOGFLARE_API_KEY = "api-key"; + +/** + * Go's Logflare entrypoint script (`start.go:358-362`): the image's own + * entrypoint conflicts with the container healthcheck due to a 15-second + * sleep, so Go writes its own `run.sh` and runs that instead. Transcribed + * byte-for-byte, including the trailing newline after `EOF` (Go's raw string + * literal ends with a newline before the closing backtick). + */ +const LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT = + "cat <<'EOF' > run.sh && sh run.sh\n./logflare eval Logflare.Release.migrate\n./logflare start --sname logflare\nEOF\n"; + +export interface LegacyLogflareContainerSpecInput { + /** + * `container.Config.Image` — the already-resolved `config.analytics.image`. + * Not part of the decoded `@supabase/config` schema (Go's own + * `Analytics.Image` field is `toml:"-"`); resolution is the caller's + * responsibility. + */ + readonly image: string; + /** Go's `Config.ProjectId`, used to derive `utils.LogflareId` via {@link legacyServiceContainerName}. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`'s target — resolved once per `start` run, not per-container. */ + readonly networkId: string; + /** `config.analytics.port` — published as `4000/tcp`. */ + readonly port: number; + /** `config.analytics.backend` (`start.go:333`). */ + readonly backend: "postgres" | "bigquery"; + /** `config.analytics.gcp_project_id` — only read when {@link backend} is `"bigquery"` (`start.go:340`). */ + readonly gcpProjectId: string; + /** `config.analytics.gcp_project_number` — only read when {@link backend} is `"bigquery"` (`start.go:341`). */ + readonly gcpProjectNumber: string; + /** + * `config.analytics.gcp_jwt_path` — only read when {@link backend} is + * `"bigquery"` (`start.go:335-336`). Joined onto {@link workdir} + * UNCONDITIONALLY, exactly like Go's own `filepath.Join(workdir, + * GcpJwtPath)` — an empty string still produces a (degenerate) bind mount of + * `workdir` itself, matching Go's behavior when the field is unset. + */ + readonly gcpJwtPath: string; + /** `os.Getwd()` at the call site (`start.go:308-311`) — the process working directory, used to resolve {@link gcpJwtPath} to a host path. */ + readonly workdir: string; + /** `dbConfig.Host` (`utils.DbId` on the default path — see `start.go:66-72`). */ + readonly dbHost: string; + /** `dbConfig.Port` (`5432` on the default path). */ + readonly dbPort: number; + /** + * `dbConfig.User` (`"postgres"` on the default path) — used only for the + * Postgres-backend `POSTGRES_BACKEND_URL` env var, NOT for + * `DB_USERNAME` (see {@link LEGACY_LOGFLARE_DB_USERNAME}'s doc comment). + */ + readonly dbUser: string; + /** `dbConfig.Password` (`Config.Db.Password`). */ + readonly dbPassword: string; +} + +/** Builds the `docker create` spec for the Logflare/analytics container (`start.go:313-394`). */ +export function legacyBuildLogflareContainerSpec( + input: LegacyLogflareContainerSpecInput, +): LegacyStartContainerSpec { + const env: Record = { + DB_DATABASE: "_supabase", + DB_HOSTNAME: input.dbHost, + DB_PORT: String(input.dbPort), + DB_SCHEMA: "_analytics", + DB_USERNAME: LEGACY_LOGFLARE_DB_USERNAME, + DB_PASSWORD: input.dbPassword, + LOGFLARE_MIN_CLUSTER_SIZE: "1", + LOGFLARE_SINGLE_TENANT: "true", + LOGFLARE_SUPABASE_MODE: "true", + LOGFLARE_PRIVATE_ACCESS_TOKEN: LEGACY_LOGFLARE_API_KEY, + LOGFLARE_LOG_LEVEL: "warn", + LOGFLARE_NODE_HOST: "127.0.0.1", + // The literal env VALUE includes the single quotes (start.go:328) — Go sets + // this directly on container.Config.Env, never through a shell, so the + // quotes are not stripped anywhere. + LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", + RELEASE_COOKIE: "cookie", + }; + + const binds: Array = []; + + if (input.backend === "bigquery") { + const hostJwtPath = join(input.workdir, input.gcpJwtPath); + binds.push(`${hostJwtPath}:/opt/app/rel/logflare/bin/gcloud.json`); + env.GOOGLE_DATASET_ID_APPEND = "_prod"; + env.GOOGLE_PROJECT_ID = input.gcpProjectId; + env.GOOGLE_PROJECT_NUMBER = input.gcpProjectNumber; + } else { + env.POSTGRES_BACKEND_URL = `postgresql://${input.dbUser}:${input.dbPassword}@${input.dbHost}:${input.dbPort}/_supabase`; + env.POSTGRES_BACKEND_SCHEMA = "_analytics"; + } + + return { + image: input.image, + containerName: legacyServiceContainerName(LEGACY_LOGFLARE_CONTAINER_SUFFIX, input.projectId), + hostname: "127.0.0.1", + env, + entrypoint: "sh", + cmd: ["-c", LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT], + binds, + exposedPorts: [{ containerPort: "4000" }], + ports: [{ hostPort: String(input.port), containerPort: "4000" }], + healthcheck: { + test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: [LEGACY_LOGFLARE_CONTAINER_SUFFIX], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts new file mode 100644 index 0000000000..881f243f7d --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts @@ -0,0 +1,111 @@ +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { + legacyBuildLogflareContainerSpec, + type LegacyLogflareContainerSpecInput, +} from "./logflare.service.ts"; + +const base: LegacyLogflareContainerSpecInput = { + image: "supabase/logflare:1.0.0", + projectId: "proj", + networkId: "supabase_network_proj", + port: 54327, + backend: "postgres", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + workdir: "/workdir", + dbHost: "supabase_db_proj", + dbPort: 5432, + dbUser: "postgres", + dbPassword: "secret", +}; + +describe("legacyBuildLogflareContainerSpec", () => { + test("builds the shared shape: identity, hostname, entrypoint/cmd, ports, healthcheck, aliases (start.go:350-394)", () => { + const spec = legacyBuildLogflareContainerSpec(base); + expect(spec.image).toBe("supabase/logflare:1.0.0"); + expect(spec.containerName).toBe("supabase_analytics_proj"); + expect(spec.hostname).toBe("127.0.0.1"); + expect(spec.entrypoint).toBe("sh"); + expect(spec.cmd).toEqual([ + "-c", + "cat <<'EOF' > run.sh && sh run.sh\n./logflare eval Logflare.Release.migrate\n./logflare start --sname logflare\nEOF\n", + ]); + expect(spec.exposedPorts).toEqual([{ containerPort: "4000" }]); + expect(spec.ports).toEqual([{ hostPort: "54327", containerPort: "4000" }]); + expect(spec.healthcheck).toEqual({ + test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.networkAliases).toEqual(["analytics"]); + expect(spec.networkId).toBe("supabase_network_proj"); + }); + + test("emits the common DB_*/LOGFLARE_* env vars regardless of backend (start.go:315-330)", () => { + const spec = legacyBuildLogflareContainerSpec(base); + expect(spec.env).toMatchObject({ + DB_DATABASE: "_supabase", + DB_HOSTNAME: "supabase_db_proj", + DB_PORT: "5432", + DB_SCHEMA: "_analytics", + DB_USERNAME: "supabase_admin", + DB_PASSWORD: "secret", + LOGFLARE_MIN_CLUSTER_SIZE: "1", + LOGFLARE_SINGLE_TENANT: "true", + LOGFLARE_SUPABASE_MODE: "true", + LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", + LOGFLARE_LOG_LEVEL: "warn", + LOGFLARE_NODE_HOST: "127.0.0.1", + LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", + RELEASE_COOKIE: "cookie", + }); + }); + + test("postgres backend: sets POSTGRES_BACKEND_URL/SCHEMA, no GCP env or bind, no bind mounts (start.go:343-347)", () => { + const spec = legacyBuildLogflareContainerSpec({ ...base, backend: "postgres" }); + expect(spec.env.POSTGRES_BACKEND_URL).toBe( + "postgresql://postgres:secret@supabase_db_proj:5432/_supabase", + ); + expect(spec.env.POSTGRES_BACKEND_SCHEMA).toBe("_analytics"); + expect(spec.env.GOOGLE_PROJECT_ID).toBeUndefined(); + expect(spec.env.GOOGLE_PROJECT_NUMBER).toBeUndefined(); + expect(spec.env.GOOGLE_DATASET_ID_APPEND).toBeUndefined(); + expect(spec.binds).toEqual([]); + }); + + test("bigquery backend: sets GOOGLE_* env and binds the host JWT path, no postgres env (start.go:334-342)", () => { + const spec = legacyBuildLogflareContainerSpec({ + ...base, + backend: "bigquery", + gcpProjectId: "my-project", + gcpProjectNumber: "123456", + gcpJwtPath: "gcloud.json", + workdir: "/workdir", + }); + expect(spec.env.GOOGLE_DATASET_ID_APPEND).toBe("_prod"); + expect(spec.env.GOOGLE_PROJECT_ID).toBe("my-project"); + expect(spec.env.GOOGLE_PROJECT_NUMBER).toBe("123456"); + expect(spec.env.POSTGRES_BACKEND_URL).toBeUndefined(); + expect(spec.env.POSTGRES_BACKEND_SCHEMA).toBeUndefined(); + expect(spec.binds).toEqual([ + `${join("/workdir", "gcloud.json")}:/opt/app/rel/logflare/bin/gcloud.json`, + ]); + }); + + test("bigquery backend still binds workdir itself when gcpJwtPath is empty, matching Go's unconditional filepath.Join", () => { + const spec = legacyBuildLogflareContainerSpec({ + ...base, + backend: "bigquery", + gcpJwtPath: "", + workdir: "/workdir", + }); + expect(spec.binds).toEqual([`${join("/workdir", "")}:/opt/app/rel/logflare/bin/gcloud.json`]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/mailpit.service.ts b/apps/cli/src/legacy/commands/start/services/mailpit.service.ts new file mode 100644 index 0000000000..9ddb6faa77 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/mailpit.service.ts @@ -0,0 +1,93 @@ +/** + * Port of Go's "Start Mailpit" block (`apps/cli-go/internal/start/start.go: + * 853-901`), gated on `config.inbucket.enabled` (`utils.Config.Inbucket.Enabled` + * in Go) — the gate itself is `start.handler.ts`'s job (a later task), not + * this module's; this file only builds the `docker create` spec. + * + * The simplest of the container-bring-up services in this port: no hostname + * override, no entrypoint/cmd override, no binds, and exactly one + * unconditional env var. The only real branching is which of the two optional + * ports (SMTP, POP3) get published alongside the always-on web UI port. + */ + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** + * `utils.InbucketAliases[0]` (`apps/cli-go/internal/utils/config.go:39`) — also + * this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG` + * (`legacy-service-catalog.ts`). Mailpit keeps the Go-internal "inbucket" name + * (the product it replaced) for the container/alias/id, even though the + * user-facing service and config section are "Mailpit"/`config.inbucket`. + */ +const LEGACY_MAILPIT_CONTAINER_SUFFIX = "inbucket"; + +export interface LegacyMailpitContainerSpecInput { + /** + * `container.Config.Image` — the already-resolved `config.inbucket.image`. + * Not part of the decoded `@supabase/config` schema (Go's own + * `Inbucket.Image` field is `toml:"-"`); resolution is the caller's + * responsibility, same as every other service in this port. + */ + readonly image: string; + /** Go's `Config.ProjectId`, used to derive `utils.InbucketId` via {@link legacyServiceContainerName}. */ + readonly projectId: string; + /** + * `container.HostConfig.NetworkMode`'s target — resolved once per `start` + * run, not per-container (see `LegacyStartContainerSpec.networkId`'s doc + * comment in `docker-create-args.ts`). + */ + readonly networkId: string; + /** `config.inbucket.port` — always published as `8025/tcp` (`start.go:855-857`). */ + readonly port: number; + /** + * `config.inbucket.smtp_port` — published as `1025/tcp` only when set and + * non-zero, matching Go's `SmtpPort != 0` guard (`start.go:858-862`). + * `@supabase/config` has no default for this key, so an absent value + * decodes to `undefined` — the same "unset" case as Go's zero `uint16`. + */ + readonly smtpPort?: number; + /** + * `config.inbucket.pop3_port` — published as `1110/tcp` only when set and + * non-zero, matching Go's `Pop3Port != 0` guard (`start.go:863-867`). + */ + readonly pop3Port?: number; +} + +/** Builds the `docker create` spec for the Mailpit/Inbucket container (`start.go:853-901`). */ +export function legacyBuildMailpitContainerSpec( + input: LegacyMailpitContainerSpecInput, +): LegacyStartContainerSpec { + const ports: Array<{ hostPort: string; containerPort: string }> = [ + { hostPort: String(input.port), containerPort: "8025" }, + ]; + if (input.smtpPort !== undefined && input.smtpPort !== 0) { + ports.push({ hostPort: String(input.smtpPort), containerPort: "1025" }); + } + if (input.pop3Port !== undefined && input.pop3Port !== 0) { + ports.push({ hostPort: String(input.pop3Port), containerPort: "1110" }); + } + + return { + image: input.image, + containerName: legacyServiceContainerName(LEGACY_MAILPIT_CONTAINER_SUFFIX, input.projectId), + env: { + // Disable reverse DNS lookups in Mailpit to avoid slow/delayed DNS resolution (start.go:873-874). + MP_SMTP_DISABLE_RDNS: "true", + }, + binds: [], + ports, + healthcheck: { + test: ["CMD", "/mailpit", "readyz"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + // StartPeriod taken from upstream Dockerfile (start.go:881-882). + startPeriodSeconds: 10, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: [LEGACY_MAILPIT_CONTAINER_SUFFIX], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/mailpit.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/mailpit.service.unit.test.ts new file mode 100644 index 0000000000..af8d104479 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/mailpit.service.unit.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "vitest"; + +import { legacyBuildMailpitContainerSpec } from "./mailpit.service.ts"; + +describe("legacyBuildMailpitContainerSpec", () => { + test("builds the minimal spec with only the always-on web UI port bound (start.go:853-901)", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "supabase/mailpit:v1", + projectId: "proj", + networkId: "supabase_network_proj", + port: 54324, + }); + + expect(spec).toEqual({ + image: "supabase/mailpit:v1", + containerName: "supabase_inbucket_proj", + env: { MP_SMTP_DISABLE_RDNS: "true" }, + binds: [], + ports: [{ hostPort: "54324", containerPort: "8025" }], + healthcheck: { + test: ["CMD", "/mailpit", "readyz"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }, + restartPolicy: "unless-stopped", + networkId: "supabase_network_proj", + networkAliases: ["inbucket"], + labels: {}, + }); + }); + + test("adds the SMTP port binding only when smtpPort is set and non-zero (start.go:858-862)", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "img", + projectId: "proj", + networkId: "net", + port: 54324, + smtpPort: 54325, + }); + expect(spec.ports).toEqual([ + { hostPort: "54324", containerPort: "8025" }, + { hostPort: "54325", containerPort: "1025" }, + ]); + }); + + test("omits the SMTP port binding when smtpPort is explicitly 0, matching Go's zero-value guard", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "img", + projectId: "proj", + networkId: "net", + port: 54324, + smtpPort: 0, + }); + expect(spec.ports).toEqual([{ hostPort: "54324", containerPort: "8025" }]); + }); + + test("adds the POP3 port binding only when pop3Port is set and non-zero (start.go:863-867)", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "img", + projectId: "proj", + networkId: "net", + port: 54324, + pop3Port: 1110, + }); + expect(spec.ports).toEqual([ + { hostPort: "54324", containerPort: "8025" }, + { hostPort: "1110", containerPort: "1110" }, + ]); + }); + + test("adds both optional ports together, in Go's declared order", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "img", + projectId: "proj", + networkId: "net", + port: 54324, + smtpPort: 54325, + pop3Port: 1110, + }); + expect(spec.ports).toEqual([ + { hostPort: "54324", containerPort: "8025" }, + { hostPort: "54325", containerPort: "1025" }, + { hostPort: "1110", containerPort: "1110" }, + ]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts new file mode 100644 index 0000000000..351ed67f7e --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts @@ -0,0 +1,82 @@ +/** + * pg-meta container spec builder — port of Go's "Start pg-meta" block + * (`apps/cli-go/internal/start/start.go:1110-1146`). Gated in Go by + * `config.studio.enabled` (pg-meta has no `enabled` flag of its own — it only + * exists to back Studio's schema browser) and + * `!isContainerExcluded(config.studio.pgmeta_image, excluded)` — see + * `legacy-service-catalog.ts`'s `pgMeta` entry (`excludeKey: "postgres-meta"`, + * gated on `studio.enabled`). Gating and image resolution/pre-pull are the + * caller's job (a future `start.handler.ts`); this module only assembles the + * container spec once the caller has already decided to start it, matching + * `docker-create-args.ts`'s "image already resolved/pulled" contract. + * + * No separately-tested pure env function the way Studio has + * (`legacyBuildStudioEnv`): pg-meta's env is 6 straight `KEY=value` + * assignments with no derived formatting or conditional logic, so + * {@link legacyBuildPgMetaContainerSpec} is the only exported entry point. + */ + +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** Go's hardcoded pg-meta listen port (`start.go:1117`, `PG_META_PORT=8080`) — never configurable. */ +const PG_META_PORT = 8080; + +/** Go's `utils.PgmetaAliases` (`apps/cli-go/internal/utils/config.go:44`) — a fixed, non-configurable constant. */ +const PG_META_NETWORK_ALIASES = ["pg_meta"]; + +export interface LegacyPgMetaContainerInput { + /** `config.studio.pgmeta_image`, already resolved/pulled by the caller. */ + readonly image: string; + /** `legacyServiceContainerName("pg_meta", projectId)` — Go's `utils.PgmetaId`. */ + readonly containerName: string; + /** + * Go's `dbConfig.Host` (`utils.DbId`, `internal/start/start.go:66`) — the + * local Postgres container's own hostname on the shared Docker network. + */ + readonly dbHost: string; + /** Go's `dbConfig.Port` (hardcoded `5432`, `start.go:67`). */ + readonly dbPort: number; + /** Go's `dbConfig.User` (hardcoded `"postgres"`, `start.go:68`). */ + readonly dbUser: string; + /** Go's `dbConfig.Password` (`config.db.password`, `legacyResolveLocalConfigValues`'s resolved value). */ + readonly dbPassword: string; + /** Go's `dbConfig.Database` (hardcoded `"postgres"`, `start.go:70`). */ + readonly dbName: string; + /** Go's `utils.NetId` — the shared Docker network every `start` container joins. */ + readonly networkId: string; +} + +/** + * Assembles pg-meta's {@link LegacyStartContainerSpec}. Pure — no Effect or + * I/O — matching `docker-create-args.ts`'s own builder shape. + */ +export function legacyBuildPgMetaContainerSpec( + input: LegacyPgMetaContainerInput, +): LegacyStartContainerSpec { + return { + image: input.image, + containerName: input.containerName, + env: { + PG_META_PORT: String(PG_META_PORT), + PG_META_DB_HOST: input.dbHost, + PG_META_DB_NAME: input.dbName, + PG_META_DB_USER: input.dbUser, + PG_META_DB_PORT: String(input.dbPort), + PG_META_DB_PASSWORD: input.dbPassword, + }, + binds: [], + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:${PG_META_PORT}/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: PG_META_NETWORK_ALIASES, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts new file mode 100644 index 0000000000..a63048a97f --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; + +import { legacyBuildPgMetaContainerSpec } from "./pg-meta.service.ts"; + +describe("legacyBuildPgMetaContainerSpec", () => { + test("assembles the full container spec from resolved inputs", () => { + const spec = legacyBuildPgMetaContainerSpec({ + image: "supabase/postgres-meta:v0.96.6", + containerName: "supabase_pg_meta_proj", + dbHost: "supabase_db_proj", + dbPort: 5432, + dbUser: "postgres", + dbPassword: "postgres", + dbName: "postgres", + networkId: "supabase_network_proj", + }); + + expect(spec).toEqual({ + image: "supabase/postgres-meta:v0.96.6", + containerName: "supabase_pg_meta_proj", + env: { + PG_META_PORT: "8080", + PG_META_DB_HOST: "supabase_db_proj", + PG_META_DB_NAME: "postgres", + PG_META_DB_USER: "postgres", + PG_META_DB_PORT: "5432", + PG_META_DB_PASSWORD: "postgres", + }, + binds: [], + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:8080/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: "supabase_network_proj", + networkAliases: ["pg_meta"], + labels: {}, + }); + }); + + test("reflects a non-default db host/port/password in env, while the healthcheck stays on the fixed container port", () => { + const spec = legacyBuildPgMetaContainerSpec({ + image: "supabase/postgres-meta:v0.96.6", + containerName: "supabase_pg_meta_proj", + dbHost: "custom-db-host", + dbPort: 6543, + dbUser: "postgres", + dbPassword: "hunter2", + dbName: "postgres", + networkId: "supabase_network_proj", + }); + + expect(spec.env["PG_META_DB_HOST"]).toBe("custom-db-host"); + expect(spec.env["PG_META_DB_PORT"]).toBe("6543"); + expect(spec.env["PG_META_DB_PASSWORD"]).toBe("hunter2"); + expect(spec.healthcheck?.test[1]).toContain("127.0.0.1:8080"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/postgres.service.ts b/apps/cli/src/legacy/commands/start/services/postgres.service.ts new file mode 100644 index 0000000000..5d008d6502 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/postgres.service.ts @@ -0,0 +1,334 @@ +/** + * Port of Go's `NewContainerConfig`/`NewHostConfig` + * (`apps/cli-go/internal/db/start/start.go:63-131`): builds the + * {@link LegacyStartContainerSpec} for `supabase start`'s Postgres container. + * + * Deliberately out of scope, per the approved start-port plan: + * - `StartDatabase`'s `fromBackup` restore branch (`start.go:143-164`, + * `templates/restore.sh`) — `supabase start` always calls `StartDatabase` + * with an empty `fromBackup` (`apps/cli-go/internal/start/start.go:295`), + * so that whole branch is dead code on this path. + * - `SetupLocalDatabase` (initial schema bootstrap, `start.go:184-187`) — an + * explicit follow-up, not container construction. + * - Actually creating/starting the container and waiting for it to become + * healthy — that's {@link legacyStartContainer} (`../lib/container-lifecycle.ts`) + * and {@link legacyWaitForHealthyServices} (`../lib/health-check.ts`), wired + * up by a later `start.handler.ts` task. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { localDbContainerId } from "../../../shared/legacy-docker-ids.ts"; +import { encodeToml } from "../../../shared/legacy-go-output.encoders.ts"; +import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../../../shared/legacy-local-config-values.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { LEGACY_START_DB_SCHEMA_SQL } from "../templates/db-schema.sql.ts"; +import { LEGACY_START_DB_SUPABASE_SQL } from "../templates/db-supabase.sql.ts"; +import { LEGACY_START_DB_WEBHOOK_SQL } from "../templates/db-webhook.sql.ts"; + +/** Go's `Db.Password` default (`pkg/config/config.go:459`). `db.password` has no + * config.toml field (`toml:"-"`, `pkg/config/db.go:88`), so this is the only value + * this port can ever observe — matches `DEFAULT_DB_PASSWORD` in + * `legacy-local-config-values.ts`, not imported from there since that constant + * isn't exported and status/stop's resolver is otherwise unrelated to this module. */ +const LEGACY_POSTGRES_PASSWORD = "postgres"; + +/** + * The exact in-container path Go's PG >= 15 entrypoint heredocs the pgsodium + * root key to (`start.go:96`) — now a `secretFiles` bind-mount target instead + * (see {@link legacyBuildPostgresStartContainerSpec}), not a heredoc. + */ +const LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.key"; + +/** Go's `container.HealthConfig` literals (`apps/cli-go/internal/db/start/start.go:85-90`). */ +const LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS = 10; +const LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS = 2; +const LEGACY_POSTGRES_HEALTHCHECK_RETRIES = 3; + +/** Go's `utils.DbAliases` (`apps/cli-go/internal/utils/config.go:36`). */ +const LEGACY_POSTGRES_NETWORK_ALIASES: ReadonlyArray = ["db", "db.supabase.internal"]; + +/** Go's version-compare threshold (`apps/cli-go/internal/db/start/start.go:79`). */ +const LEGACY_POSTGRES_INITDB_VERSION_THRESHOLD = "15.8.1.005"; + +const LEGACY_POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; + +export interface LegacyPostgresStartServiceInput { + /** Decoded `[db]` section — every field this builder needs (`port`, `major_version`, `settings`) lives here. */ + readonly db: ProjectConfig["db"]; + /** Decoded `[experimental]` section — only the OrioleDB/S3 fields are read. */ + readonly experimental: ProjectConfig["experimental"]; + /** Already-resolved (default-or-configured, decrypted) `auth.jwt_secret` — same shape `legacyResolveLocalConfigValues` produces. */ + readonly jwtSecret: string; + /** `config.auth.jwt_expiry`. */ + readonly jwtExpiry: number; + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s doc comment. */ + readonly projectId: string; + /** `utils.NetId` — the local stack's docker network id. */ + readonly networkId: string; + /** `utils.Config.Db.Image`, already resolved/pulled (see `../lib/image-prepull.ts`) — the container's own image. */ + readonly image: string; + /** + * `utils.Config.Db.Image` BEFORE registry resolution — Go's + * `POSTGRES_INITDB_ARGS` version-tag comparison (`start.go:79`) always runs + * against this un-rewritten value; the registry candidate only ever + * overwrites the container's `Image` field, afterward, inside `DockerStart` + * (`docker.go:365,371`). Passed separately from {@link image} because a + * `SUPABASE_INTERNAL_IMAGE_REGISTRY` override containing a port (e.g. + * `localhost:5000`) would otherwise inject an extra colon that breaks + * {@link legacyPostgresImageVersionTag}'s first-colon tag split. + */ + readonly configImage: string; + /** Already-resolved `db.root_key` value. Defaults to {@link LEGACY_POSTGRES_DEFAULT_ROOT_KEY} when omitted — see that constant's doc comment for why. */ + readonly rootKey?: string; +} + +/** + * Port of Go's `(a *settings) ToPostgresConfig()` + * (`apps/cli-go/pkg/config/db.go:181-190`): serializes `db.settings` as TOML — + * only the fields actually set, matching Go's nil-pointer fields never being + * written — replaces every `"` with `'`, and prepends the fixed header + * comment. + * + * Reuses the shared {@link encodeToml} (`legacy-go-output.encoders.ts`, backed + * by `smol-toml`) for the actual line rendering: `smol-toml`'s `stringifyTable` + * already skips `undefined`/`null` values exactly like Go's TOML encoder + * (`github.com/BurntSushi/toml`'s `eStruct`) skips nil pointers — verified + * against that library's source, which omits a nil field unconditionally, with + * no `omitempty` tag required — and its integer/string/boolean formatting + * already matches Go's (unquoted numbers/bools, double-quoted strings, single- + * quoted here afterward). The one divergence: `smol-toml`'s `stringify` always + * appends a trailing `\n`, even for an empty object (`stringify({})` → + * `"\n"`), whereas Go's `ToTomlBytes` of an all-nil-pointer struct returns the + * empty string (`TestSettingsToPostgresConfig`'s "Empty settings should + * result in empty string" case) — so the empty-settings case is special-cased + * below instead of delegated to `encodeToml`. + * + * `settings` itself is typed optional (`ProjectConfig["db"]["settings"]` + * includes `undefined`) because `db.ts` wraps the whole `[db.settings]` table + * in `Schema.optionalKey` — in practice the schema's own `withDecodingDefaultKey` + * always fills in `{}` when the section is absent, but this stays defensive + * against the static type either way, matching Go's `settings` being a plain + * (never-nil) struct value. + */ +export function legacyPostgresSettingsToPostgresConfig( + settings: ProjectConfig["db"]["settings"], +): string { + const defined = Object.fromEntries( + Object.entries(settings ?? {}).filter(([, value]) => value !== undefined), + ); + if (Object.keys(defined).length === 0) { + return LEGACY_POSTGRES_CONFIG_HEADER; + } + const toml = encodeToml(defined).replaceAll('"', "'"); + return `${LEGACY_POSTGRES_CONFIG_HEADER}${toml}`; +} + +/** + * Port of Go's `config.VersionCompare` (`apps/cli-go/pkg/config/config.go:885-899`) + * — NOT a real semver comparator. A dotted version with more than 3 components + * truncates to its first 3 as the primary comparison key, and compares the + * remaining components — joined and left-trimmed of leading `0` characters — + * as a secondary tie-break (Go: `semver.Compare("v"+pA, "v"+pB)`). Both real + * inputs at this module's one call site (a Postgres image tag and the + * `"15.8.1.005"` threshold) always have exactly 4 numeric components, so this + * only reproduces Go's `golang.org/x/mod/semver` invalid-version rule (an + * invalid version string sorts before a valid one; two invalid strings + * compare equal — exercised by Go's own `TestVersionCompare` `"oriole-17"` + * cases) for the narrower set of shapes real inputs can take; full semver + * pre-release/build-metadata syntax is out of scope. + */ +export function legacyPostgresVersionCompare(a: string, b: string): number { + const [aHead, aTail] = legacySplitVersionHeadTail(a); + const [bHead, bTail] = legacySplitVersionHeadTail(b); + const headCompare = legacyCompareVersionStrings(aHead, bHead); + if (headCompare !== 0) return headCompare; + return legacyCompareVersionStrings(aTail, bTail); +} + +function legacySplitVersionHeadTail(version: string): readonly [string, string] { + const parts = version.split("."); + if (parts.length <= 3) return [version, ""]; + return [parts.slice(0, 3).join("."), parts.slice(3).join(".").replace(/^0+/, "")]; +} + +function legacyIsValidDottedVersion(version: string): boolean { + return version.length > 0 && version.split(".").every((part) => /^\d+$/.test(part)); +} + +function legacyCompareVersionStrings(a: string, b: string): number { + const aValid = legacyIsValidDottedVersion(a); + const bValid = legacyIsValidDottedVersion(b); + if (!aValid || !bValid) { + return aValid === bValid ? 0 : aValid ? 1 : -1; + } + const aParts = a.split(".").map(Number); + const bParts = b.split(".").map(Number); + const length = Math.max(aParts.length, bParts.length); + for (let index = 0; index < length; index += 1) { + const diff = (aParts[index] ?? 0) - (bParts[index] ?? 0); + if (diff !== 0) return diff > 0 ? 1 : -1; + } + return 0; +} + +/** + * Go's `i := strings.IndexByte(utils.Config.Db.Image, ':'); ...Image[i+1:]` + * (`apps/cli-go/internal/db/start/start.go:79`) — the FIRST colon splits the + * image name from its tag. Go's own `Db.Image` is never registry-prefixed at + * this point in Go's pipeline (registry resolution only overwrites the + * container's `Image` field afterward, inside `DockerStart` — + * `docker.go:365,371`), so the first colon is always the name/tag separator. + * The caller MUST pass the pre-registry-rewrite image (see + * {@link LegacyPostgresStartServiceInput.configImage}) — a resolved image can + * carry a registry host prefix with its own colon (e.g. a + * `SUPABASE_INTERNAL_IMAGE_REGISTRY=localhost:5000` override), which would + * otherwise be misparsed as the tag. When no colon is present at all, Go's + * slice expression degrades to the whole string (`Image[0:]`) — reproduced + * here the same way. + */ +export function legacyPostgresImageVersionTag(image: string): string { + const colonIndex = image.indexOf(":"); + return colonIndex === -1 ? image : image.slice(colonIndex + 1); +} + +/** + * Go's OrioleDB / version-compare `Env` branch (`start.go:70-81`) — an + * `else if`, so at most one of the two ever fires. + */ +function legacyPostgresExtraEnv( + experimental: ProjectConfig["experimental"], + image: string, +): Readonly> { + if (experimental.orioledb_version !== undefined && experimental.orioledb_version.length > 0) { + return { + POSTGRES_INITDB_ARGS: "--lc-collate=C --lc-ctype=C", + S3_ENABLED: "true", + S3_HOST: experimental.s3_host ?? "", + S3_REGION: experimental.s3_region ?? "", + S3_ACCESS_KEY: experimental.s3_access_key ?? "", + S3_SECRET_KEY: experimental.s3_secret_key ?? "", + }; + } + const tag = legacyPostgresImageVersionTag(image); + if (legacyPostgresVersionCompare(tag, LEGACY_POSTGRES_INITDB_VERSION_THRESHOLD) < 0) { + return { POSTGRES_INITDB_ARGS: "--lc-collate=C.UTF-8" }; + } + return {}; +} + +/** + * PG >= 15 entrypoint (`config.db.major_version > 14`, Go's default branch, + * `start.go:91-104`): writes `/etc/postgresql.schema.sql` (schema.sql + + * webhook.sql + _supabase.sql, concatenated in that exact order), appends + * `postgresConfig` to `postgresql.conf`, then execs `docker-entrypoint.sh`. + * Go also heredocs `/etc/postgresql-custom/pgsodium_root.key` directly into + * this same script (`start.go:96`) — safe for Go, which calls + * `Docker.ContainerCreate` over the Engine API directly rather than shelling + * out. THIS PORT SHELLS OUT to a real `docker create`, so it deliberately + * diverges here: the pgsodium root key travels via + * {@link LegacyStartContainerSpec.secretFiles} instead (a HOST temp file, + * mode `0644` — world-readable, because Postgres's entrypoint drops root and + * reads this file back as the `postgres` user, and a Linux/Podman bind mount + * preserves the host file's mode verbatim; see `legacyStageStartSecretFiles`'s + * doc comment — bind-mounted read-only at that exact path — see + * {@link legacyBuildPostgresStartContainerSpec}), so it never appears in this + * process's own `docker create` argv (CWE-214/522). + * + * Otherwise byte-for-byte derived from Go's raw-string concatenation + * (including the trailing space after `/etc/postgresql` — Go's + * `NewContainerConfig(args ...string)` joins its variadic `args` there, + * always empty for `supabase start`, so the space survives as-is); built via + * explicit `"...\n" +` concatenation rather than a multi-line template + * literal so that trailing space stays a visible, lint/format-proof string + * character instead of invisible end-of-line whitespace. + */ +function legacyPostgresEntrypointScriptPg15(postgresConfig: string): string { + return ( + "\n" + + "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `${LEGACY_START_DB_SCHEMA_SQL}\n` + + `${LEGACY_START_DB_WEBHOOK_SQL}\n` + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${postgresConfig}\n` + + "EOF" + ); +} + +/** + * PG <= 14 entrypoint (`start.go:106-113`): a shorter script — no + * `schema.sql`/`webhook.sql` (PG >= 15 only) and no pgsodium root key file — + * writes `/docker-entrypoint-initdb.d/supabase_schema.sql` (_supabase.sql + * only), appends `postgresConfig` to `postgresql.conf`, then execs + * `docker-entrypoint.sh`. See {@link legacyPostgresEntrypointScriptPg15}'s doc + * comment for why this is explicit concatenation rather than a template + * literal. + */ +function legacyPostgresEntrypointScriptPg14(postgresConfig: string): string { + return ( + "\n" + + "cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${postgresConfig}\n` + + "EOF" + ); +} + +/** + * Builds the {@link LegacyStartContainerSpec} for `supabase start`'s Postgres + * container — see this module's header for what's deliberately out of scope. + */ +export function legacyBuildPostgresStartContainerSpec( + input: LegacyPostgresStartServiceInput, +): LegacyStartContainerSpec { + const containerName = localDbContainerId(input.projectId); + const rootKeyValue = input.rootKey ?? LEGACY_POSTGRES_DEFAULT_ROOT_KEY; + const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); + const isPg14OrEarlier = input.db.major_version <= 14; + + const env: Record = { + POSTGRES_PASSWORD: LEGACY_POSTGRES_PASSWORD, + POSTGRES_HOST: "/var/run/postgresql", + JWT_SECRET: input.jwtSecret, + JWT_EXP: String(input.jwtExpiry), + ...legacyPostgresExtraEnv(input.experimental, input.configImage), + }; + + const script = isPg14OrEarlier + ? legacyPostgresEntrypointScriptPg14(postgresConfig) + : legacyPostgresEntrypointScriptPg15(postgresConfig); + + return { + image: input.image, + containerName, + env, + entrypoint: "sh", + cmd: ["-c", script], + binds: [`${containerName}:/var/lib/postgresql/data`], + ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), + ...(isPg14OrEarlier + ? {} + : { + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, + ], + }), + ports: [{ hostPort: String(input.db.port), containerPort: "5432" }], + healthcheck: { + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, + timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, + retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: LEGACY_POSTGRES_NETWORK_ALIASES, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts new file mode 100644 index 0000000000..b651045860 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts @@ -0,0 +1,315 @@ +import type { ProjectConfig } from "@supabase/config"; +import { describe, expect, test } from "vitest"; + +import { LEGACY_START_DB_SCHEMA_SQL } from "../templates/db-schema.sql.ts"; +import { LEGACY_START_DB_SUPABASE_SQL } from "../templates/db-supabase.sql.ts"; +import { LEGACY_START_DB_WEBHOOK_SQL } from "../templates/db-webhook.sql.ts"; +import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../../../shared/legacy-local-config-values.ts"; +import { + legacyBuildPostgresStartContainerSpec, + legacyPostgresImageVersionTag, + legacyPostgresSettingsToPostgresConfig, + legacyPostgresVersionCompare, + type LegacyPostgresStartServiceInput, +} from "./postgres.service.ts"; + +const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; + +function baseDb(overrides: Partial = {}): ProjectConfig["db"] { + return { + port: 54322, + shadow_port: 54320, + health_timeout: "2m", + major_version: 17, + pooler: { + enabled: false, + port: 54329, + pool_mode: "transaction", + default_pool_size: 20, + max_client_conn: 100, + }, + migrations: { enabled: true, schema_paths: [] }, + seed: { enabled: true, sql_paths: [] }, + settings: {}, + network_restrictions: { enabled: false, allowed_cidrs: [], allowed_cidrs_v6: [] }, + ...overrides, + }; +} + +function baseExperimental( + overrides: Partial = {}, +): ProjectConfig["experimental"] { + return { + webhooks: { enabled: false }, + pgdelta: { enabled: false }, + inspect: { rules: [] }, + ...overrides, + }; +} + +function baseInput( + overrides: Partial = {}, +): LegacyPostgresStartServiceInput { + return { + db: baseDb(), + experimental: baseExperimental(), + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + projectId: "myproj", + networkId: "supabase_network_myproj", + image: "public.ecr.aws/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + ...overrides, + }; +} + +describe("legacyBuildPostgresStartContainerSpec", () => { + test("PG >= 15: concatenates schema.sql + webhook.sql + _supabase.sql into the schema heredoc, appends the postgres config, and carries the pgsodium root key as a secretFile instead of a heredoc", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ db: baseDb({ major_version: 17 }) }), + ); + + expect(spec.entrypoint).toBe("sh"); + const script = spec.cmd?.[1]; + expect(script).toBe( + "\n" + + "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `${LEGACY_START_DB_SCHEMA_SQL}\n` + + `${LEGACY_START_DB_WEBHOOK_SQL}\n` + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${POSTGRES_CONFIG_HEADER}\n` + + "EOF", + ); + expect(script).not.toContain(LEGACY_POSTGRES_DEFAULT_ROOT_KEY); + expect(script).not.toContain("pgsodium_root.key"); + expect(spec.tmpfs).toBeUndefined(); + expect(spec.secretFiles).toEqual([ + { + containerPath: "/etc/postgresql-custom/pgsodium_root.key", + content: LEGACY_POSTGRES_DEFAULT_ROOT_KEY, + }, + ]); + }); + + test("PG <= 14: writes only _supabase.sql (no schema.sql/webhook.sql, no pgsodium root key) and sets the initdb tmpfs mount", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ db: baseDb({ major_version: 14 }) }), + ); + + const script = spec.cmd?.[1]; + expect(script).toBe( + "\n" + + "cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${POSTGRES_CONFIG_HEADER}\n` + + "EOF", + ); + expect(script).not.toContain("postgresql.schema.sql"); + expect(script).not.toContain("pgsodium_root.key"); + expect(spec.tmpfs).toEqual({ "/docker-entrypoint-initdb.d": "" }); + expect(spec.secretFiles).toBeUndefined(); + }); + + test("uses a rootKey override instead of the Go default when provided", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ db: baseDb({ major_version: 17 }), rootKey: "custom-root-key" }), + ); + expect(spec.secretFiles).toEqual([ + { containerPath: "/etc/postgresql-custom/pgsodium_root.key", content: "custom-root-key" }, + ]); + expect(spec.cmd?.[1]).not.toContain("custom-root-key"); + expect(spec.cmd?.[1]).not.toContain(LEGACY_POSTGRES_DEFAULT_ROOT_KEY); + }); + + test("base env carries password, host, jwt secret, and jwt expiry", () => { + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.env).toMatchObject({ + POSTGRES_PASSWORD: "postgres", + POSTGRES_HOST: "/var/run/postgresql", + JWT_SECRET: "super-secret-jwt-token-with-at-least-32-characters-long", + JWT_EXP: "3600", + }); + }); + + test("OrioleDB branch: adds POSTGRES_INITDB_ARGS + S3 env vars and skips the version-compare branch", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ + experimental: baseExperimental({ + orioledb_version: "17.4.1.030", + s3_host: "s3.example.com", + s3_region: "us-east-1", + s3_access_key: "access-key", + s3_secret_key: "secret-key", + }), + // Old enough to trigger the version-compare branch too, if it weren't skipped. + image: "supabase/postgres:orioledb-15.1.0.55", + }), + ); + expect(spec.env).toEqual({ + POSTGRES_PASSWORD: "postgres", + POSTGRES_HOST: "/var/run/postgresql", + JWT_SECRET: "super-secret-jwt-token-with-at-least-32-characters-long", + JWT_EXP: "3600", + POSTGRES_INITDB_ARGS: "--lc-collate=C --lc-ctype=C", + S3_ENABLED: "true", + S3_HOST: "s3.example.com", + S3_REGION: "us-east-1", + S3_ACCESS_KEY: "access-key", + S3_SECRET_KEY: "secret-key", + }); + }); + + test("OrioleDB branch defaults unset S3 fields to empty strings, matching Go's zero-value string fields", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ experimental: baseExperimental({ orioledb_version: "17.4.1.030" }) }), + ); + expect(spec.env).toMatchObject({ + S3_HOST: "", + S3_REGION: "", + S3_ACCESS_KEY: "", + S3_SECRET_KEY: "", + }); + }); + + test("version-compare branch: adds POSTGRES_INITDB_ARGS=--lc-collate=C.UTF-8 when the image tag is below the threshold", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: "public.ecr.aws/supabase/postgres:15.1.0.117", + configImage: "supabase/postgres:15.1.0.117", + }), + ); + expect(spec.env.POSTGRES_INITDB_ARGS).toBe("--lc-collate=C.UTF-8"); + }); + + test("version-compare branch is skipped when the image tag is at or above the threshold", () => { + const atThreshold = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: "public.ecr.aws/supabase/postgres:15.8.1.005", + configImage: "supabase/postgres:15.8.1.005", + }), + ); + expect(atThreshold.env.POSTGRES_INITDB_ARGS).toBeUndefined(); + + const aboveThreshold = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: "public.ecr.aws/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + }), + ); + expect(aboveThreshold.env.POSTGRES_INITDB_ARGS).toBeUndefined(); + }); + + test("version-compare branch reads the pre-registry-rewrite configImage, not the registry-resolved image (a port-bearing registry override must not break the tag parse)", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: "localhost:5000/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + }), + ); + expect(spec.env.POSTGRES_INITDB_ARGS).toBeUndefined(); + }); + + test("healthcheck matches Go's pg_isready probe", () => { + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.healthcheck).toEqual({ + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + }); + + test("port binding maps the configured db.port to container port 5432", () => { + const spec = legacyBuildPostgresStartContainerSpec(baseInput({ db: baseDb({ port: 12345 }) })); + expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); + }); + + test("binds a named volume keyed by the container's own (sanitized) name", () => { + const spec = legacyBuildPostgresStartContainerSpec(baseInput({ projectId: "my project!" })); + expect(spec.containerName).toBe("supabase_db_my_project_"); + expect(spec.binds).toEqual(["supabase_db_my_project_:/var/lib/postgresql/data"]); + }); + + test("network id, aliases, restart policy, and image pass through unchanged", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ networkId: "supabase_network_myproj", image: "some/resolved-image:17.4.1.030" }), + ); + expect(spec.networkId).toBe("supabase_network_myproj"); + expect(spec.networkAliases).toEqual(["db", "db.supabase.internal"]); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.image).toBe("some/resolved-image:17.4.1.030"); + expect(spec.labels).toEqual({}); + }); +}); + +describe("legacyPostgresSettingsToPostgresConfig", () => { + test("only set values appear, in the configured field's TOML form", () => { + const config = legacyPostgresSettingsToPostgresConfig({ + max_connections: 100, + max_locks_per_transaction: 64, + shared_buffers: "128MB", + work_mem: "4MB", + }); + expect(config).toContain("max_connections = 100"); + expect(config).toContain("max_locks_per_transaction = 64"); + expect(config).toContain("shared_buffers = '128MB'"); + expect(config).toContain("work_mem = '4MB'"); + expect(config).not.toContain("effective_cache_size"); + expect(config).not.toContain("maintenance_work_mem"); + expect(config).not.toContain("max_parallel_workers"); + }); + + test("session_replication_role is single-quoted like every other string field", () => { + const config = legacyPostgresSettingsToPostgresConfig({ session_replication_role: "origin" }); + expect(config).toContain("session_replication_role = 'origin'"); + }); + + test("empty settings produce just the header, with no trailing content", () => { + const config = legacyPostgresSettingsToPostgresConfig({}); + expect(config).toBe(POSTGRES_CONFIG_HEADER); + expect(config).not.toContain("="); + }); + + test("a boolean field is emitted unquoted", () => { + const config = legacyPostgresSettingsToPostgresConfig({ track_commit_timestamp: true }); + expect(config).toContain("track_commit_timestamp = true"); + }); +}); + +describe("legacyPostgresVersionCompare", () => { + test.each([ + ["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], + ] as const)("VersionCompare(%s, %s) === %d", (a, b, expected) => { + expect(legacyPostgresVersionCompare(a, b)).toBe(expected); + }); +}); + +describe("legacyPostgresImageVersionTag", () => { + test("extracts the tag after the last colon, ignoring any registry host prefix", () => { + expect(legacyPostgresImageVersionTag("public.ecr.aws/supabase/postgres:15.1.0.117")).toBe( + "15.1.0.117", + ); + expect(legacyPostgresImageVersionTag("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", () => { + expect(legacyPostgresImageVersionTag("supabase/postgres")).toBe("supabase/postgres"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/postgrest.service.ts b/apps/cli/src/legacy/commands/start/services/postgrest.service.ts new file mode 100644 index 0000000000..c935d8a1f1 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/postgrest.service.ts @@ -0,0 +1,114 @@ +/** + * Port of Go's "Start PostgREST" block + * (`apps/cli-go/internal/start/start.go:960-992`). + * + * Enabled gate: `config.api.enabled` (`utils.Config.Api.Enabled`, + * `start.go:961`). Gating (this field, plus `!isContainerExcluded`) is the + * caller's responsibility — see `start.services.ts`'s `postgrest` catalog + * entry (`enabledGate: "api.enabled"`). + * + * No `Healthcheck` field at all: Go's own `container.Config` literal for this + * service (`start.go:964-976`) sets only `Image`/`Env`, with the comment + * `// PostgREST does not expose a shell for health check` in place of a + * `Healthcheck:` entry — confirmed by reading the struct literal itself, not + * just the comment. PostgREST readiness is instead checked at runtime via an + * HTTP HEAD through the local Kong gateway + * (`legacyCheckHttpReady`/`LEGACY_POSTGREST_READY_PATH`, `../lib/health-check.ts`, + * itself porting `status.go:159-229`'s "PostgREST does not support native + * health checks" branch) — this builder correctly omits `healthcheck` so + * `legacyBuildStartContainerCreateArgs` never emits a `--health-*` flag for + * this container, matching `docker-create-args.ts`'s own documented + * PostgREST exception. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "../lib/internal-db-connection.ts"; + +export interface LegacyPostgrestEnvInput { + /** `config.api.schemas` — joined with `,` into `PGRST_DB_SCHEMAS`. */ + readonly schemas: ProjectConfig["api"]["schemas"]; + /** `config.api.extra_search_path` — joined with `,` into `PGRST_DB_EXTRA_SEARCH_PATH`. */ + readonly extraSearchPath: ProjectConfig["api"]["extra_search_path"]; + /** `config.api.max_rows`. */ + readonly maxRows: ProjectConfig["api"]["max_rows"]; + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ + readonly dbHost: string; + /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ + readonly dbPassword: string; + /** + * `legacyResolveLocalJwks`'s resolved JWKS JSON string — feeds + * `PGRST_JWT_SECRET` directly (Go's `fmt.Sprintf("PGRST_JWT_SECRET=%s", + * jwks)`, `start.go:972`; despite the env var's name, PostgREST is fed the + * JWKS document, not the raw `auth.jwt_secret`). + */ + readonly jwks: string; +} + +/** + * Pure env-var builder, split out from + * {@link legacyBuildPostgrestContainerSpec} so the full Go `Env` literal + * (`start.go:966-973`) is unit-testable without constructing a whole + * container spec. + */ +export function legacyBuildPostgrestEnv(input: LegacyPostgrestEnvInput): Record { + return { + PGRST_DB_URI: legacyStartInternalDbUrl("authenticator", input.dbHost, input.dbPassword), + PGRST_DB_SCHEMAS: input.schemas.join(","), + PGRST_DB_EXTRA_SEARCH_PATH: input.extraSearchPath.join(","), + PGRST_DB_MAX_ROWS: String(input.maxRows), + PGRST_DB_ANON_ROLE: "anon", + PGRST_JWT_SECRET: input.jwks, + PGRST_ADMIN_SERVER_PORT: "3001", + }; +} + +export interface LegacyPostgrestContainerSpecInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.Api.Image`, already resolved/pulled by the caller (`image-prepull.ts`). */ + readonly image: string; + readonly schemas: ProjectConfig["api"]["schemas"]; + readonly extraSearchPath: ProjectConfig["api"]["extra_search_path"]; + readonly maxRows: ProjectConfig["api"]["max_rows"]; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB password. */ + readonly dbUrl: string; + readonly jwks: string; +} + +/** + * Builds the `docker create` spec for the PostgREST container + * (`start.go:960-992`). No `ports`/`exposedPorts` either — Go's + * `container.Config` literal for this service declares neither. + */ +export function legacyBuildPostgrestContainerSpec( + input: LegacyPostgrestContainerSpecInput, +): LegacyStartContainerSpec { + const env = legacyBuildPostgrestEnv({ + schemas: input.schemas, + extraSearchPath: input.extraSearchPath, + maxRows: input.maxRows, + dbHost: legacyServiceContainerName("db", input.projectId), + dbPassword: legacyStartInternalDbPassword(input.dbUrl), + jwks: input.jwks, + }); + + return { + image: input.image, + containerName: legacyServiceContainerName("rest", input.projectId), + env, + binds: [], + restartPolicy: "unless-stopped", + networkId: input.networkId, + // `utils.RestAliases = []string{"rest"}` (`utils/config.go:41`). + networkAliases: ["rest"], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/postgrest.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/postgrest.service.unit.test.ts new file mode 100644 index 0000000000..957b845820 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/postgrest.service.unit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildPostgrestContainerSpec, + legacyBuildPostgrestEnv, + type LegacyPostgrestContainerSpecInput, +} from "./postgrest.service.ts"; + +describe("legacyBuildPostgrestEnv", () => { + const base = { + schemas: ["public", "graphql_public"], + extraSearchPath: ["public", "extensions"], + maxRows: 1000, + dbHost: "supabase_db_proj", + dbPassword: "postgres", + jwks: '{"keys":[]}', + }; + + test("wires PGRST_DB_URI as the authenticator role against the internal DB address", () => { + const env = legacyBuildPostgrestEnv(base); + expect(env["PGRST_DB_URI"]).toBe( + "postgresql://authenticator:postgres@supabase_db_proj:5432/postgres", + ); + }); + + test("joins schemas and extra_search_path with commas", () => { + const env = legacyBuildPostgrestEnv(base); + expect(env["PGRST_DB_SCHEMAS"]).toBe("public,graphql_public"); + expect(env["PGRST_DB_EXTRA_SEARCH_PATH"]).toBe("public,extensions"); + }); + + test("feeds the resolved JWKS string into PGRST_JWT_SECRET, not the raw jwt secret", () => { + const env = legacyBuildPostgrestEnv({ ...base, jwks: '{"keys":["fake"]}' }); + expect(env["PGRST_JWT_SECRET"]).toBe('{"keys":["fake"]}'); + }); + + test("matches Go's remaining static env values", () => { + const env = legacyBuildPostgrestEnv(base); + expect(env).toMatchObject({ + PGRST_DB_MAX_ROWS: "1000", + PGRST_DB_ANON_ROLE: "anon", + PGRST_ADMIN_SERVER_PORT: "3001", + }); + }); +}); + +describe("legacyBuildPostgrestContainerSpec", () => { + const input: LegacyPostgrestContainerSpecInput = { + projectId: "proj", + networkId: "supabase_network_proj", + image: "supabase/postgrest:v12", + schemas: ["public"], + extraSearchPath: ["public"], + maxRows: 1000, + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + jwks: '{"keys":[]}', + }; + + test("derives the container name and internal DB host from projectId", () => { + const spec = legacyBuildPostgrestContainerSpec(input); + expect(spec.containerName).toBe("supabase_rest_proj"); + expect(spec.env["PGRST_DB_URI"]).toBe( + "postgresql://authenticator:postgres@supabase_db_proj:5432/postgres", + ); + }); + + test("has no healthcheck, no ports, and no exposedPorts — matching Go's PostgREST container.Config", () => { + const spec = legacyBuildPostgrestContainerSpec(input); + expect(spec.healthcheck).toBeUndefined(); + expect(spec.ports).toBeUndefined(); + expect(spec.exposedPorts).toBeUndefined(); + }); + + test("network alias is 'rest'", () => { + const spec = legacyBuildPostgrestContainerSpec(input); + expect(spec.networkAliases).toEqual(["rest"]); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + }); + + test("reuses (does not recompute) the resolved db password from a non-default dbUrl", () => { + const spec = legacyBuildPostgrestContainerSpec({ + ...input, + dbUrl: "postgresql://postgres:another-secret@127.0.0.1:54322/postgres", + }); + expect(spec.env["PGRST_DB_URI"]).toBe( + "postgresql://authenticator:another-secret@supabase_db_proj:5432/postgres", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.ts new file mode 100644 index 0000000000..1f0571da34 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.ts @@ -0,0 +1,158 @@ +/** + * Port of Go's "Start Realtime" block + * (`apps/cli-go/internal/start/start.go:903-958`). + * + * Enabled gate: `config.realtime.enabled` (`utils.Config.Realtime.Enabled`, + * `start.go:904`) — independent of `config.api.enabled` (PostgREST's own + * gate, `api.go:961`); the two are never conflated in Go. Gating (this field, + * plus `!isContainerExcluded`) is the caller's responsibility — see + * `start.services.ts`'s `realtime` catalog entry (`enabledGate: + * "realtime.enabled"`) — this module only builds the container spec once + * called. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { + legacyStartInternalDbPassword, + LEGACY_START_INTERNAL_DB_NAME, + LEGACY_START_INTERNAL_DB_PORT, +} from "../lib/internal-db-connection.ts"; + +/** + * Go's `utils.SUPERUSER_ROLE` (`apps/cli-go/internal/utils/connect.go:338`) — + * Realtime's fixed `DB_USER` (`start.go:913`). Unrelated to the per-service + * role each OTHER container's own DB connection string uses (PostgREST's + * `authenticator`, Storage's `supabase_storage_admin`), so it is not hoisted + * alongside `legacyStartInternalDbUrl`. + */ +const LEGACY_REALTIME_DB_USER = "supabase_admin"; + +/** + * Go's `realtime.TenantId` default (`pkg/config/config.go:481`) — `toml:"-"` + * (`config.go:254`), so never configurable via `config.toml` or a + * `SUPABASE_*` override; always this literal. Exported: `kong.service.ts`'s + * `kong.yml` template needs this exact same value for its `RealtimeId` field + * (Go's `Config.Realtime.TenantId`, `start.go:492` — NOT Realtime's own + * container name/id, see that module's `realtimeTenantId` doc comment). + */ +export const LEGACY_REALTIME_TENANT_ID = "realtime-dev"; + +/** Go's `realtime.EncryptionKey` default (`pkg/config/config.go:482`) — `toml:"-"`, never configurable. */ +const LEGACY_REALTIME_ENCRYPTION_KEY = "supabaserealtime"; + +/** Go's `realtime.SecretKeyBase` default (`pkg/config/config.go:483`) — `toml:"-"`, never configurable. */ +const LEGACY_REALTIME_SECRET_KEY_BASE = + "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG"; + +export interface LegacyRealtimeEnvInput { + /** `config.realtime.ip_version` — feeds `utils.ToRealtimeEnv` (`utils/config.go:209-214`). */ + readonly ipVersion: ProjectConfig["realtime"]["ip_version"]; + /** `config.realtime.max_header_length`. */ + readonly maxHeaderLength: ProjectConfig["realtime"]["max_header_length"]; + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ + readonly dbHost: string; + /** See {@link legacyStartInternalDbPassword}. */ + readonly dbPassword: string; + /** `LegacyLocalConfigValues.jwtSecret` — feeds both `API_JWT_SECRET` and `METRICS_JWT_SECRET`. */ + readonly jwtSecret: string; + /** `legacyResolveLocalJwks`'s resolved JWKS JSON string — feeds `API_JWT_JWKS`. */ + readonly jwks: string; +} + +/** + * Pure env-var builder, split out from {@link legacyBuildRealtimeContainerSpec} + * so the full Go `Env` literal (`start.go:909-929`) is unit-testable without + * constructing a whole container spec. + */ +export function legacyBuildRealtimeEnv(input: LegacyRealtimeEnvInput): Record { + return { + PORT: "4000", + DB_HOST: input.dbHost, + DB_PORT: String(LEGACY_START_INTERNAL_DB_PORT), + DB_USER: LEGACY_REALTIME_DB_USER, + DB_PASSWORD: input.dbPassword, + DB_NAME: LEGACY_START_INTERNAL_DB_NAME, + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + DB_ENC_KEY: LEGACY_REALTIME_ENCRYPTION_KEY, + API_JWT_SECRET: input.jwtSecret, + API_JWT_JWKS: input.jwks, + METRICS_JWT_SECRET: input.jwtSecret, + APP_NAME: "realtime", + SECRET_KEY_BASE: LEGACY_REALTIME_SECRET_KEY_BASE, + ERL_AFLAGS: input.ipVersion === "IPv6" ? "-proto_dist inet6_tcp" : "-proto_dist inet_tcp", + // Two literal single-quote characters, exactly like Go's `"DNS_NODES=''"` (`start.go:924`). + DNS_NODES: "''", + RLIMIT_NOFILE: "", + SEED_SELF_HOST: "true", + RUN_JANITOR: "true", + MAX_HEADER_LENGTH: String(input.maxHeaderLength), + }; +} + +export interface LegacyRealtimeContainerSpecInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.Realtime.Image`, already resolved/pulled by the caller (`image-prepull.ts`). */ + readonly image: string; + readonly ipVersion: ProjectConfig["realtime"]["ip_version"]; + readonly maxHeaderLength: ProjectConfig["realtime"]["max_header_length"]; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB password. */ + readonly dbUrl: string; + readonly jwtSecret: string; + readonly jwks: string; +} + +/** + * Builds the `docker create` spec for the Realtime container + * (`start.go:903-958`). No `ports` (host-published) entry — Realtime, like + * GoTrue, only ever `ExposedPorts` its port on the Docker network + * (`nat.PortSet{"4000/tcp": {}}`, `start.go:930`). + */ +export function legacyBuildRealtimeContainerSpec( + input: LegacyRealtimeContainerSpecInput, +): LegacyStartContainerSpec { + const env = legacyBuildRealtimeEnv({ + ipVersion: input.ipVersion, + maxHeaderLength: input.maxHeaderLength, + dbHost: legacyServiceContainerName("db", input.projectId), + dbPassword: legacyStartInternalDbPassword(input.dbUrl), + jwtSecret: input.jwtSecret, + jwks: input.jwks, + }); + + return { + image: input.image, + containerName: legacyServiceContainerName("realtime", input.projectId), + env, + binds: [], + exposedPorts: [{ containerPort: "4000" }], + healthcheck: { + // Podman splits command by spaces unless quoted, but curl's header can't be quoted + // (`start.go:932`, reproduced verbatim as this exec-form `test` array). + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + `Host:${LEGACY_REALTIME_TENANT_ID}`, + "http://127.0.0.1:4000/api/ping", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + // `utils.RealtimeAliases = []string{"realtime", Config.Realtime.TenantId}` (`utils/config.go:40`). + networkAliases: ["realtime", LEGACY_REALTIME_TENANT_ID], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts new file mode 100644 index 0000000000..dfbf24b95d --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildRealtimeContainerSpec, + legacyBuildRealtimeEnv, + type LegacyRealtimeContainerSpecInput, +} from "./realtime.service.ts"; + +describe("legacyBuildRealtimeEnv", () => { + const base = { + ipVersion: "IPv4" as const, + maxHeaderLength: 4096, + dbHost: "supabase_db_proj", + dbPassword: "postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + }; + + test("wires the fixed internal DB address, JWT secret, and JWKS", () => { + const env = legacyBuildRealtimeEnv(base); + expect(env["DB_HOST"]).toBe("supabase_db_proj"); + expect(env["DB_PORT"]).toBe("5432"); + expect(env["DB_USER"]).toBe("supabase_admin"); + expect(env["DB_PASSWORD"]).toBe("postgres"); + expect(env["DB_NAME"]).toBe("postgres"); + expect(env["API_JWT_SECRET"]).toBe(base.jwtSecret); + expect(env["METRICS_JWT_SECRET"]).toBe(base.jwtSecret); + expect(env["API_JWT_JWKS"]).toBe(base.jwks); + }); + + test("matches Go's remaining static env values", () => { + const env = legacyBuildRealtimeEnv(base); + expect(env).toMatchObject({ + PORT: "4000", + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + DB_ENC_KEY: "supabaserealtime", + APP_NAME: "realtime", + SECRET_KEY_BASE: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + DNS_NODES: "''", + RLIMIT_NOFILE: "", + SEED_SELF_HOST: "true", + RUN_JANITOR: "true", + MAX_HEADER_LENGTH: "4096", + }); + }); + + test("selects inet_tcp for IPv4", () => { + expect(legacyBuildRealtimeEnv({ ...base, ipVersion: "IPv4" })["ERL_AFLAGS"]).toBe( + "-proto_dist inet_tcp", + ); + }); + + test("selects inet6_tcp for IPv6", () => { + expect(legacyBuildRealtimeEnv({ ...base, ipVersion: "IPv6" })["ERL_AFLAGS"]).toBe( + "-proto_dist inet6_tcp", + ); + }); +}); + +describe("legacyBuildRealtimeContainerSpec", () => { + const input: LegacyRealtimeContainerSpecInput = { + projectId: "proj", + networkId: "supabase_network_proj", + image: "supabase/realtime:v2", + ipVersion: "IPv4", + maxHeaderLength: 4096, + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + }; + + test("derives the container name and internal DB host from projectId", () => { + const spec = legacyBuildRealtimeContainerSpec(input); + expect(spec.containerName).toBe("supabase_realtime_proj"); + expect(spec.env["DB_HOST"]).toBe("supabase_db_proj"); + expect(spec.env["DB_PASSWORD"]).toBe("postgres"); + }); + + test("exposes port 4000 with no host-published port binding", () => { + const spec = legacyBuildRealtimeContainerSpec(input); + expect(spec.exposedPorts).toEqual([{ containerPort: "4000" }]); + expect(spec.ports).toBeUndefined(); + }); + + test("builds the exec-form healthcheck with the tenant id host header", () => { + const spec = legacyBuildRealtimeContainerSpec(input); + expect(spec.healthcheck).toEqual({ + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + "Host:realtime-dev", + "http://127.0.0.1:4000/api/ping", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + }); + + test("network aliases are 'realtime' and the tenant id", () => { + const spec = legacyBuildRealtimeContainerSpec(input); + expect(spec.networkAliases).toEqual(["realtime", "realtime-dev"]); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + }); + + test("reuses (does not recompute) the resolved db password from a non-default dbUrl", () => { + const spec = legacyBuildRealtimeContainerSpec({ + ...input, + dbUrl: "postgresql://postgres:another-secret@127.0.0.1:54322/postgres", + }); + expect(spec.env["DB_PASSWORD"]).toBe("another-secret"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.ts b/apps/cli/src/legacy/commands/start/services/storage.service.ts new file mode 100644 index 0000000000..439a465b9e --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -0,0 +1,266 @@ +/** + * Port of Go's "Start Storage" block + * (`apps/cli-go/internal/start/start.go:994-1057`), plus the vector-bucket + * env helper `appendStorageVectorEnv` (`start.go:1487-1501`). + * + * Enabled gate: `isStorageEnabled` (`start.go:301`) — + * `config.storage.enabled && !isContainerExcluded(storageImage, excluded)`. + * Gating itself is the caller's responsibility (`start.services.ts`'s + * `storage` catalog entry, `enabledGate: "storage.enabled"`); this module + * only builds the container spec once called. + * + * `imageTransformationEnabled` (below) is Go's `isImgProxyEnabled` — a + * COMPOUND value (`storage.enabled` is already implied by the fact Storage + * itself is starting) `&& config.storage.image_transformation?.enabled && + * !isContainerExcluded(imgproxyImage, excluded)` (`start.go:302-303`), NOT + * the bare `config.storage.image_transformation?.enabled` field alone. The + * caller must compute this compound boolean exactly once and pass the SAME + * value both here (`ENABLE_IMAGE_TRANSFORMATION`) and to the decision of + * whether to actually start the ImgProxy container + * (`imgproxy.service.ts`'s `legacyBuildImgproxyContainerSpec` precondition) + * — the two must never disagree, matching Go's single shared + * `isImgProxyEnabled` local variable (`start.go:995,1011,1060`). + * + * `s3ProtocolEnabled` is `config.storage.s3_protocol.enabled` directly (no + * exclusion factor — S3 protocol support is a Storage feature flag, not a + * separate container). Go models `S3Protocol` as a nilable pointer + * (`*s3Protocol`, `pkg/config/storage.go:17`) that stays `nil` only when + * `[storage.s3_protocol]` is entirely absent from BOTH the user's + * `config.toml` and the embedded default template — but the embedded + * template always sets `[storage.s3_protocol]\nenabled = true` + * (`pkg/config/templates/config.toml:128-129`) and is merged in as the base + * layer before any real config loads, so `S3Protocol` is non-nil for every + * config `Config.Load` actually produces. `@supabase/config`'s schema + * mirrors this by decoding `storage.s3_protocol` unconditionally (never + * `optionalKey`, unlike `image_transformation`), so the raw decoded boolean + * needs no extra PRESENCE check — but the caller is still responsible for + * applying `SUPABASE_STORAGE_S3_PROTOCOL_ENABLED`/`SUPABASE_STORAGE_VECTOR_ + * ENABLED` (Go's generic Viper `AutomaticEnv` override, same mechanism + * `imageTransformationEnabled`'s own compound gate already accounts for) + * before passing `s3ProtocolEnabled`/`vectorBucketsEnabled` in. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import { ramInBytes } from "../../../shared/legacy-size-units.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; +import { + legacyStartInternalDbUrl, + legacyStartInternalDbPassword, +} from "../lib/internal-db-connection.ts"; + +/** Go's `dockerStoragePath` local (`start.go:996`) — both the container's `FILE_STORAGE_BACKEND_PATH` and its named-volume mount target. */ +const LEGACY_STORAGE_DOCKER_PATH = "/mnt"; + +export interface LegacyStorageVectorEnvInput { + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ + readonly dbHost: string; + /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ + readonly dbPassword: string; + readonly projectEnvValues?: Readonly>; +} + +/** + * Port of `appendStorageVectorEnv(env []string, dbConfig pgconn.Config) + * []string` (`start.go:1473-1501`). Only called when + * `config.storage.vector.enabled` (Go's `isVectorBucketsEnabled`, + * `start.go:305,1022-1024`) — note the TOML key is `[storage.vector]`, not + * `vector_buckets`; Go's Go-side struct field is named `VectorBuckets` but + * tagged `toml:"vector"` (`pkg/config/storage.go:21`). + */ +export function legacyAppendStorageVectorEnv( + env: Readonly>, + input: LegacyStorageVectorEnvInput, +): Record { + const defaultVectorUrl = legacyStartInternalDbUrl("postgres", input.dbHost, input.dbPassword); + return { + ...env, + VECTOR_ENABLED: legacyEnvOrDefault("VECTOR_ENABLED", "true", input.projectEnvValues), + VECTOR_BUCKET_PROVIDER: legacyEnvOrDefault( + "VECTOR_BUCKET_PROVIDER", + "pgvector", + input.projectEnvValues, + ), + VECTOR_STORE_MIGRATIONS_ENABLED: legacyEnvOrDefault( + "VECTOR_STORE_MIGRATIONS_ENABLED", + "true", + input.projectEnvValues, + ), + VECTOR_DATABASE_URL: legacyEnvOrDefault( + "VECTOR_DATABASE_URL", + defaultVectorUrl, + input.projectEnvValues, + ), + }; +} + +export interface LegacyStorageEnvInput { + /** + * Go's `utils.Config.Storage.TargetMigration` (`pkg/config/storage.go:13`) — + * `toml:"-"`, resolved from a version-pin file + * (`builder.StorageMigrationPath`, `config.go:844-846`), not from + * `@supabase/config`'s schema. Out of scope for this builder (like `image`); + * the caller resolves it and typically passes `""` when the file is absent, + * matching Go's zero-value string default. + */ + readonly targetMigration: string; + /** `LegacyLocalConfigValues.anonKey`. */ + readonly anonKey: string; + /** `LegacyLocalConfigValues.serviceRoleKey`. */ + readonly serviceRoleKey: string; + /** `LegacyLocalConfigValues.jwtSecret`. */ + readonly jwtSecret: string; + /** `legacyResolveLocalJwks`'s resolved JWKS JSON string. */ + readonly jwks: string; + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ + readonly dbHost: string; + /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ + readonly dbPassword: string; + /** `config.storage.file_size_limit`, e.g. `"50MiB"` — converted to a byte count via `ramInBytes`. */ + readonly fileSizeLimit: ProjectConfig["storage"]["file_size_limit"]; + /** `LegacyLocalConfigValues.storageS3Region`. */ + readonly s3Region: string; + /** `LegacyLocalConfigValues.storageS3AccessKeyId`. */ + readonly s3AccessKeyId: string; + /** `LegacyLocalConfigValues.storageS3SecretAccessKey`. */ + readonly s3SecretAccessKey: string; + /** Go's compound `isImgProxyEnabled` — see this file's header for why this is NOT the bare config field. */ + readonly imageTransformationEnabled: boolean; + /** The ImgProxy container's own Docker name (`legacyServiceContainerName("imgproxy", projectId)`). */ + readonly imgproxyHost: string; + /** `config.storage.s3_protocol.enabled` — see this file's header for why no extra presence check is needed. */ + readonly s3ProtocolEnabled: boolean; + /** `config.storage.vector.enabled` (Go's `isVectorBucketsEnabled`). */ + readonly vectorBucketsEnabled: boolean; + readonly projectEnvValues?: Readonly>; +} + +/** + * Pure env-var builder, split out from {@link legacyBuildStorageContainerSpec} + * so the full Go `storageEnv` literal (`start.go:997-1021`) — including the + * conditional vector-bucket branch — is unit-testable without constructing a + * whole container spec. + */ +export function legacyBuildStorageEnv(input: LegacyStorageEnvInput): Record { + const env: Record = { + DB_MIGRATIONS_FREEZE_AT: input.targetMigration, + ANON_KEY: input.anonKey, + SERVICE_KEY: input.serviceRoleKey, + AUTH_JWT_SECRET: input.jwtSecret, + JWT_JWKS: input.jwks, + DATABASE_URL: legacyStartInternalDbUrl( + "supabase_storage_admin", + input.dbHost, + input.dbPassword, + ), + FILE_SIZE_LIMIT: String(ramInBytes(input.fileSizeLimit)), + STORAGE_BACKEND: "file", + FILE_STORAGE_BACKEND_PATH: LEGACY_STORAGE_DOCKER_PATH, + TENANT_ID: "stub", + // TODO (matches Go's own TODO, `start.go:1008`): https://github.com/supabase/storage-api/issues/55 + STORAGE_S3_REGION: input.s3Region, + GLOBAL_S3_BUCKET: "stub", + ENABLE_IMAGE_TRANSFORMATION: String(input.imageTransformationEnabled), + IMGPROXY_URL: `http://${input.imgproxyHost}:5001`, + TUS_URL_PATH: "/storage/v1/upload/resumable", + S3_PROTOCOL_ENABLED: String(input.s3ProtocolEnabled), + S3_PROTOCOL_ACCESS_KEY_ID: input.s3AccessKeyId, + S3_PROTOCOL_ACCESS_KEY_SECRET: input.s3SecretAccessKey, + S3_PROTOCOL_PREFIX: "/storage/v1", + UPLOAD_FILE_SIZE_LIMIT: "52428800000", + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", + }; + + return input.vectorBucketsEnabled + ? legacyAppendStorageVectorEnv(env, { + dbHost: input.dbHost, + dbPassword: input.dbPassword, + projectEnvValues: input.projectEnvValues, + }) + : env; +} + +export interface LegacyStorageContainerSpecInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.Storage.Image`, already resolved/pulled by the caller (`image-prepull.ts`). */ + readonly image: string; + readonly targetMigration: string; + readonly fileSizeLimit: ProjectConfig["storage"]["file_size_limit"]; + readonly s3Region: string; + readonly s3AccessKeyId: string; + readonly s3SecretAccessKey: string; + readonly s3ProtocolEnabled: boolean; + readonly imageTransformationEnabled: boolean; + readonly vectorBucketsEnabled: boolean; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB password. */ + readonly dbUrl: string; + readonly jwtSecret: string; + readonly jwks: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly projectEnvValues?: Readonly>; +} + +/** + * Builds the `docker create` spec for the Storage container + * (`start.go:994-1057`). `binds` mounts the container's own named volume at + * `/mnt` (`container.HostConfig.Binds: []string{utils.StorageId + ":" + + * dockerStoragePath}`, `start.go:1043`) — no `ports`/`exposedPorts`, Storage + * is reached only via its Docker network alias. + */ +export function legacyBuildStorageContainerSpec( + input: LegacyStorageContainerSpecInput, +): LegacyStartContainerSpec { + const containerName = legacyServiceContainerName("storage", input.projectId); + const env = legacyBuildStorageEnv({ + targetMigration: input.targetMigration, + anonKey: input.anonKey, + serviceRoleKey: input.serviceRoleKey, + jwtSecret: input.jwtSecret, + jwks: input.jwks, + dbHost: legacyServiceContainerName("db", input.projectId), + dbPassword: legacyStartInternalDbPassword(input.dbUrl), + fileSizeLimit: input.fileSizeLimit, + s3Region: input.s3Region, + s3AccessKeyId: input.s3AccessKeyId, + s3SecretAccessKey: input.s3SecretAccessKey, + imageTransformationEnabled: input.imageTransformationEnabled, + imgproxyHost: legacyServiceContainerName("imgproxy", input.projectId), + s3ProtocolEnabled: input.s3ProtocolEnabled, + vectorBucketsEnabled: input.vectorBucketsEnabled, + projectEnvValues: input.projectEnvValues, + }); + + return { + image: input.image, + containerName, + env, + binds: [`${containerName}:${LEGACY_STORAGE_DOCKER_PATH}`], + healthcheck: { + // "For some reason, localhost resolves to IPv6 address on GitPod which breaks + // healthcheck." (`start.go:1031`) — reproduced verbatim, IPv4 loopback pinned. + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + // `utils.StorageAliases = []string{"storage"}` (`utils/config.go:42`). + networkAliases: ["storage"], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts new file mode 100644 index 0000000000..0efe020753 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyAppendStorageVectorEnv, + legacyBuildStorageContainerSpec, + legacyBuildStorageEnv, + type LegacyStorageContainerSpecInput, + type LegacyStorageEnvInput, +} from "./storage.service.ts"; + +const baseEnvInput: LegacyStorageEnvInput = { + targetMigration: "", + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + dbHost: "supabase_db_proj", + dbPassword: "postgres", + fileSizeLimit: "50MiB", + s3Region: "local", + s3AccessKeyId: "625729a08b95bf1b7ff351a663f3a23c", + s3SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + imageTransformationEnabled: false, + imgproxyHost: "supabase_imgproxy_proj", + s3ProtocolEnabled: true, + vectorBucketsEnabled: false, +}; + +describe("legacyBuildStorageEnv", () => { + test("wires the resolved auth keys, JWT secret, and JWKS", () => { + const env = legacyBuildStorageEnv(baseEnvInput); + expect(env["ANON_KEY"]).toBe("anon-key"); + expect(env["SERVICE_KEY"]).toBe("service-role-key"); + expect(env["AUTH_JWT_SECRET"]).toBe(baseEnvInput.jwtSecret); + expect(env["JWT_JWKS"]).toBe(baseEnvInput.jwks); + }); + + test("wires DATABASE_URL as the supabase_storage_admin role against the internal DB address", () => { + const env = legacyBuildStorageEnv(baseEnvInput); + expect(env["DATABASE_URL"]).toBe( + "postgresql://supabase_storage_admin:postgres@supabase_db_proj:5432/postgres", + ); + }); + + test("wires the resolved S3 credentials, not hardcoded literals", () => { + const env = legacyBuildStorageEnv({ + ...baseEnvInput, + s3AccessKeyId: "custom-access-key", + s3SecretAccessKey: "custom-secret-key", + s3Region: "custom-region", + }); + expect(env["S3_PROTOCOL_ACCESS_KEY_ID"]).toBe("custom-access-key"); + expect(env["S3_PROTOCOL_ACCESS_KEY_SECRET"]).toBe("custom-secret-key"); + expect(env["STORAGE_S3_REGION"]).toBe("custom-region"); + }); + + test("converts file_size_limit from a human-readable string to a byte count", () => { + const env = legacyBuildStorageEnv({ ...baseEnvInput, fileSizeLimit: "5MiB" }); + expect(env["FILE_SIZE_LIMIT"]).toBe(String(5 * 1024 * 1024)); + }); + + test("matches Go's remaining static env values", () => { + const env = legacyBuildStorageEnv(baseEnvInput); + expect(env).toMatchObject({ + STORAGE_BACKEND: "file", + FILE_STORAGE_BACKEND_PATH: "/mnt", + TENANT_ID: "stub", + GLOBAL_S3_BUCKET: "stub", + TUS_URL_PATH: "/storage/v1/upload/resumable", + S3_PROTOCOL_PREFIX: "/storage/v1", + UPLOAD_FILE_SIZE_LIMIT: "52428800000", + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", + }); + }); + + describe("image-transformation / ImgProxy gate", () => { + test("ENABLE_IMAGE_TRANSFORMATION and IMGPROXY_URL reflect the caller-resolved compound flag", () => { + const disabled = legacyBuildStorageEnv({ + ...baseEnvInput, + imageTransformationEnabled: false, + }); + expect(disabled["ENABLE_IMAGE_TRANSFORMATION"]).toBe("false"); + + const enabled = legacyBuildStorageEnv({ ...baseEnvInput, imageTransformationEnabled: true }); + expect(enabled["ENABLE_IMAGE_TRANSFORMATION"]).toBe("true"); + }); + + test("IMGPROXY_URL always points at the imgproxy container regardless of the gate", () => { + const env = legacyBuildStorageEnv(baseEnvInput); + expect(env["IMGPROXY_URL"]).toBe("http://supabase_imgproxy_proj:5001"); + }); + }); + + describe("S3 protocol gate", () => { + test("S3_PROTOCOL_ENABLED reflects config.storage.s3_protocol.enabled directly", () => { + expect( + legacyBuildStorageEnv({ ...baseEnvInput, s3ProtocolEnabled: true })["S3_PROTOCOL_ENABLED"], + ).toBe("true"); + expect( + legacyBuildStorageEnv({ ...baseEnvInput, s3ProtocolEnabled: false })["S3_PROTOCOL_ENABLED"], + ).toBe("false"); + }); + }); + + describe("vector-buckets env branch", () => { + test("omits every VECTOR_* key when vectorBucketsEnabled is false", () => { + const env = legacyBuildStorageEnv({ ...baseEnvInput, vectorBucketsEnabled: false }); + expect(env["VECTOR_ENABLED"]).toBeUndefined(); + expect(env["VECTOR_BUCKET_PROVIDER"]).toBeUndefined(); + expect(env["VECTOR_STORE_MIGRATIONS_ENABLED"]).toBeUndefined(); + expect(env["VECTOR_DATABASE_URL"]).toBeUndefined(); + }); + + test("appends the four VECTOR_* keys with Go's defaults when enabled and no override is set", () => { + const env = legacyBuildStorageEnv({ ...baseEnvInput, vectorBucketsEnabled: true }); + expect(env["VECTOR_ENABLED"]).toBe("true"); + expect(env["VECTOR_BUCKET_PROVIDER"]).toBe("pgvector"); + expect(env["VECTOR_STORE_MIGRATIONS_ENABLED"]).toBe("true"); + expect(env["VECTOR_DATABASE_URL"]).toBe( + "postgresql://postgres:postgres@supabase_db_proj:5432/postgres", + ); + }); + + test("VECTOR_DATABASE_URL defaults to the postgres role, distinct from DATABASE_URL's storage_admin role", () => { + const env = legacyBuildStorageEnv({ ...baseEnvInput, vectorBucketsEnabled: true }); + expect(env["VECTOR_DATABASE_URL"]).not.toBe(env["DATABASE_URL"]); + expect(env["VECTOR_DATABASE_URL"]).toContain("postgres:postgres@"); + expect(env["DATABASE_URL"]).toContain("supabase_storage_admin:postgres@"); + }); + + test("respects a projectEnvValues override over the default, matching Go's envOrDefault", () => { + const env = legacyBuildStorageEnv({ + ...baseEnvInput, + vectorBucketsEnabled: true, + projectEnvValues: { VECTOR_ENABLED: "false", VECTOR_BUCKET_PROVIDER: "custom" }, + }); + expect(env["VECTOR_ENABLED"]).toBe("false"); + expect(env["VECTOR_BUCKET_PROVIDER"]).toBe("custom"); + // Unoverridden keys still fall back to Go's defaults. + expect(env["VECTOR_STORE_MIGRATIONS_ENABLED"]).toBe("true"); + }); + + test("an override that is set but empty is used verbatim, matching os.LookupEnv (not treated as unset)", () => { + const env = legacyBuildStorageEnv({ + ...baseEnvInput, + vectorBucketsEnabled: true, + projectEnvValues: { VECTOR_BUCKET_PROVIDER: "" }, + }); + expect(env["VECTOR_BUCKET_PROVIDER"]).toBe(""); + }); + }); +}); + +describe("legacyAppendStorageVectorEnv", () => { + test("preserves the base env and appends the four vector keys", () => { + const base = { EXISTING_KEY: "unchanged" }; + const appended = legacyAppendStorageVectorEnv(base, { + dbHost: "supabase_db_proj", + dbPassword: "postgres", + }); + expect(appended["EXISTING_KEY"]).toBe("unchanged"); + expect(Object.keys(appended)).toEqual( + expect.arrayContaining([ + "VECTOR_ENABLED", + "VECTOR_BUCKET_PROVIDER", + "VECTOR_STORE_MIGRATIONS_ENABLED", + "VECTOR_DATABASE_URL", + ]), + ); + }); +}); + +describe("legacyBuildStorageContainerSpec", () => { + const input: LegacyStorageContainerSpecInput = { + projectId: "proj", + networkId: "supabase_network_proj", + image: "supabase/storage-api:v1", + targetMigration: "", + fileSizeLimit: "50MiB", + s3Region: "local", + s3AccessKeyId: "625729a08b95bf1b7ff351a663f3a23c", + s3SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + s3ProtocolEnabled: true, + imageTransformationEnabled: false, + vectorBucketsEnabled: false, + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + }; + + test("derives the container name, DB host, and imgproxy host from projectId", () => { + const spec = legacyBuildStorageContainerSpec(input); + expect(spec.containerName).toBe("supabase_storage_proj"); + expect(spec.env["DATABASE_URL"]).toBe( + "postgresql://supabase_storage_admin:postgres@supabase_db_proj:5432/postgres", + ); + expect(spec.env["IMGPROXY_URL"]).toBe("http://supabase_imgproxy_proj:5001"); + }); + + test("mounts its own named volume at /mnt, with no ports/exposedPorts", () => { + const spec = legacyBuildStorageContainerSpec(input); + expect(spec.binds).toEqual(["supabase_storage_proj:/mnt"]); + expect(spec.ports).toBeUndefined(); + expect(spec.exposedPorts).toBeUndefined(); + }); + + test("builds the wget-based healthcheck against the IPv4 loopback", () => { + const spec = legacyBuildStorageContainerSpec(input); + expect(spec.healthcheck).toEqual({ + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + }); + + test("network alias is 'storage'", () => { + const spec = legacyBuildStorageContainerSpec(input); + expect(spec.networkAliases).toEqual(["storage"]); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + }); + + test("propagates the image-transformation gate through to ENABLE_IMAGE_TRANSFORMATION", () => { + const withImgproxy = legacyBuildStorageContainerSpec({ + ...input, + imageTransformationEnabled: true, + }); + expect(withImgproxy.env["ENABLE_IMAGE_TRANSFORMATION"]).toBe("true"); + + const withoutImgproxy = legacyBuildStorageContainerSpec({ + ...input, + imageTransformationEnabled: false, + }); + expect(withoutImgproxy.env["ENABLE_IMAGE_TRANSFORMATION"]).toBe("false"); + }); + + test("propagates the vector-buckets flag through to the container env", () => { + const spec = legacyBuildStorageContainerSpec({ ...input, vectorBucketsEnabled: true }); + expect(spec.env["VECTOR_ENABLED"]).toBe("true"); + expect(spec.env["VECTOR_DATABASE_URL"]).toBe( + "postgresql://postgres:postgres@supabase_db_proj:5432/postgres", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.ts b/apps/cli/src/legacy/commands/start/services/studio.service.ts new file mode 100644 index 0000000000..5bca44b936 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/studio.service.ts @@ -0,0 +1,221 @@ +/** + * Studio env + container spec builder — port of Go's "Start Studio" block + * (`apps/cli-go/internal/start/start.go:1148-1191`) and its `buildStudioEnv` + * helper (`start.go:1319-1347`), ported faithfully as {@link legacyBuildStudioEnv} + * — see `apps/cli-go/internal/start/start_test.go:522 TestBuildStudioEnv`, + * ported in `studio.service.unit.test.ts`. Gated in Go by `config.studio.enabled` + * and `!isContainerExcluded(config.studio.image, excluded)` — see + * `legacy-service-catalog.ts`'s `studio` entry (`excludeKey: "studio"`, gated + * on `studio.enabled`, depends on pg-meta being healthy/running). Gating, + * image resolution/pre-pull, and edge-function bind-mount resolution + * (`serve.PopulatePerFunctionConfigs`, out of scope here — see + * {@link LegacyStudioContainerInput.functionBinds}) are the caller's job (a + * future `start.handler.ts`). + * + * `workdir`/`containerSnippetsPath`: Go computes `hostSnippetsPath := + * filepath.Join(workdir, utils.SnippetsDir)` where `utils.SnippetsDir = + * filepath.Join("supabase", "snippets")` (`apps/cli-go/internal/utils/misc.go:107`) + * — a plain top-level `/supabase/snippets` directory, NOT under + * `supabase/.temp/` (that directory is reserved for Go's own link-state cache, + * see `legacy-temp-paths.ts`; snippets are user content Studio's SQL Editor + * reads/writes, meant to persist, not a cache). `containerSnippetsPath` is + * that host path translated to its in-container mount form + * (`utils.ToDockerPath` — see {@link legacyToDockerPath}), computed once by + * {@link legacyBuildStudioContainerSpec} and threaded into both the bind mount + * and {@link legacyBuildStudioEnv}'s `SNIPPETS_MANAGEMENT_FOLDER`, mirroring + * Go's `run()` computing it once for both uses. + */ + +import { join } from "node:path"; + +import { legacyToDockerPath } from "../../../shared/legacy-docker-path.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** Container-internal port Studio listens on — Go's hardcoded `3000/tcp` (`start.go:1166,1174`). */ +const STUDIO_CONTAINER_PORT = 3000; + +/** Go's `utils.StudioAliases` (`apps/cli-go/internal/utils/config.go:45`) — a fixed, non-configurable constant. */ +const STUDIO_NETWORK_ALIASES = ["studio"]; + +/** + * Go's `Config.Analytics.ApiKey` default (`pkg/config/config.go:529`, + * `NewConfig()`'s `Analytics: analytics{ApiKey: "api-key", ...}`). That field + * is `toml:"-"` (`pkg/config/config.go:307`) — never decoded from + * `config.toml`, never overridable — so this is always the value regardless of + * the resolved project config, exactly like `legacy-local-config-values.ts`'s + * other Go-hardcoded, schema-absent constants (`DEFAULT_DB_PASSWORD`, the S3 + * credential triple). + */ +const LOGFLARE_PRIVATE_ACCESS_TOKEN = "api-key"; + +export interface LegacyBuildStudioEnvInput { + /** Go's `dbConfig.Password` (`start.go:1323`) — becomes `POSTGRES_PASSWORD`. */ + readonly dbPassword: string; + /** + * Go's `workdir` (`os.Getwd()` in `run()`, `start.go:308`; matches + * `LegacyCliConfig.workdir`, the already-resolved absolute project root) — + * `EDGE_FUNCTIONS_MANAGEMENT_FOLDER` is resolved against it + * (`filepath.Join(workdir, utils.FunctionsDir)`, `start.go:1341`). + */ + readonly workdir: string; + /** + * Go's `containerSnippetsPath` (`start.go:1157`) — becomes + * `SNIPPETS_MANAGEMENT_FOLDER` verbatim. See this module's doc comment for + * what it is and how it's derived. + */ + readonly containerSnippetsPath: string; + /** Go's `utils.Version` — `CURRENT_CLI_VERSION`. */ + readonly cliVersion: string; + /** pg-meta's own container name (Go's `utils.PgmetaId`) — `STUDIO_PG_META_URL=http://:8080`. */ + readonly pgMetaContainerName: string; + /** Kong's own container name (Go's `utils.KongId`) — `SUPABASE_URL=http://:8000`. */ + readonly kongContainerName: string; + /** Logflare's own container name (Go's `utils.LogflareId`) — `LOGFLARE_URL=http://:4000`. */ + readonly logflareContainerName: string; + /** + * `config.studio.api_url`, post-`SUPABASE_STUDIO_API_URL`-override AND + * post-`Config.Validate`'s host-rewrite (`legacyResolveStudioApiUrl`, + * `pkg/config/config.go:1074-1078`) — `SUPABASE_PUBLIC_URL`. Distinct from + * `LegacyLocalConfigValues.studioUrl` (the `http://:` value + * `status` reports). This is NOT simply the raw `studio.api_url` field: + * under a default config its host (`127.0.0.1`) matches the local hostname, + * so Go rewrites it to `Config.Api.ExternalUrl` (the Kong URL) before + * `start` ever reads it — the caller must apply that same rewrite before + * passing this field in. + */ + readonly studioApiUrl: string; + /** `legacyResolveLocalConfigValues(...).jwtSecret` — `AUTH_JWT_SECRET`. */ + readonly jwtSecret: string; + /** `legacyResolveLocalConfigValues(...).anonKey` — `SUPABASE_ANON_KEY`. */ + readonly anonKey: string; + /** `legacyResolveLocalConfigValues(...).serviceRoleKey` — `SUPABASE_SERVICE_KEY`. */ + readonly serviceRoleKey: string; + /** `legacyResolveLocalConfigValues(...).publishableKey` — `SUPABASE_PUBLISHABLE_KEY`. */ + readonly publishableKey: string; + /** `legacyResolveLocalConfigValues(...).secretKey` — `SUPABASE_SECRET_KEY`. */ + readonly secretKey: string; + /** `legacyResolveLocalConfigValues(...).storageS3AccessKeyId` — `S3_PROTOCOL_ACCESS_KEY_ID`. */ + readonly s3AccessKeyId: string; + /** `legacyResolveLocalConfigValues(...).storageS3SecretAccessKey` — `S3_PROTOCOL_ACCESS_KEY_SECRET`. */ + readonly s3SecretAccessKey: string; + /** + * `config.studio.openai_api_key`, decrypted/resolved by the caller — + * `OPENAI_API_KEY`. `undefined` maps to `""`, matching Go's unset `Secret` + * zero value (`utils.Config.Studio.OpenaiApiKey.Value`). + */ + readonly openaiApiKey: string | undefined; + /** `config.api.schemas` — `PGRST_DB_SCHEMAS`, comma-joined. */ + readonly apiSchemas: ReadonlyArray; + /** `config.api.extra_search_path` — `PGRST_DB_EXTRA_SEARCH_PATH`, comma-joined. */ + readonly apiExtraSearchPath: ReadonlyArray; + /** `config.api.max_rows` — `PGRST_DB_MAX_ROWS`. */ + readonly apiMaxRows: number; + /** `legacyEnvOverrideBool`-resolved `analytics.enabled` — `NEXT_PUBLIC_ENABLE_LOGS`. */ + readonly analyticsEnabled: boolean; + /** `config.analytics.backend`, post-`SUPABASE_ANALYTICS_BACKEND`-override — `NEXT_ANALYTICS_BACKEND_PROVIDER`. */ + readonly analyticsBackend: "postgres" | "bigquery"; +} + +/** + * Port of Go's `buildStudioEnv(dbConfig pgconn.Config, workdir, + * containerSnippetsPath string) []string` (`start.go:1319-1347`). Returns a + * `KEY -> value` map rather than Go's `KEY=value` string slice — + * {@link LegacyStartContainerSpec.env}'s own shape, chosen so secret values + * never round-trip through this process's own `docker create` argv (see that + * field's doc comment in `docker-create-args.ts`). Pure — no Effect or I/O — + * so every env var mapping is unit-testable in isolation; ported test: + * `apps/cli-go/internal/start/start_test.go:522 TestBuildStudioEnv`. + */ +export function legacyBuildStudioEnv(input: LegacyBuildStudioEnvInput): Record { + return { + CURRENT_CLI_VERSION: input.cliVersion, + STUDIO_PG_META_URL: `http://${input.pgMetaContainerName}:8080`, + POSTGRES_PASSWORD: input.dbPassword, + SUPABASE_URL: `http://${input.kongContainerName}:8000`, + SUPABASE_PUBLIC_URL: input.studioApiUrl, + AUTH_JWT_SECRET: input.jwtSecret, + SUPABASE_ANON_KEY: input.anonKey, + SUPABASE_SERVICE_KEY: input.serviceRoleKey, + SUPABASE_PUBLISHABLE_KEY: input.publishableKey, + SUPABASE_SECRET_KEY: input.secretKey, + S3_PROTOCOL_ACCESS_KEY_ID: input.s3AccessKeyId, + S3_PROTOCOL_ACCESS_KEY_SECRET: input.s3SecretAccessKey, + LOGFLARE_PRIVATE_ACCESS_TOKEN, + OPENAI_API_KEY: input.openaiApiKey ?? "", + PGRST_DB_SCHEMAS: input.apiSchemas.join(","), + PGRST_DB_EXTRA_SEARCH_PATH: input.apiExtraSearchPath.join(","), + PGRST_DB_MAX_ROWS: String(input.apiMaxRows), + LOGFLARE_URL: `http://${input.logflareContainerName}:4000`, + NEXT_PUBLIC_ENABLE_LOGS: String(input.analyticsEnabled), + NEXT_ANALYTICS_BACKEND_PROVIDER: input.analyticsBackend, + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: legacyToDockerPath( + join(input.workdir, "supabase", "functions"), + ), + SNIPPETS_MANAGEMENT_FOLDER: input.containerSnippetsPath, + // Ref: https://github.com/vercel/next.js/issues/51684#issuecomment-1612834913 + HOSTNAME: "0.0.0.0", + POSTGRES_USER_READ_WRITE: "postgres", + }; +} + +export interface LegacyStudioContainerInput { + /** `config.studio.image`, already resolved/pulled by the caller. */ + readonly image: string; + /** `legacyServiceContainerName("studio", projectId)` — Go's `utils.StudioId`. */ + readonly containerName: string; + /** Go's `utils.NetId` — the shared Docker network every `start` container joins. */ + readonly networkId: string; + /** `config.studio.port` — the host port published to `3000/tcp`. */ + readonly port: number; + /** + * Go's `serve.PopulatePerFunctionConfigs(workdir, "", nil, fsys)` binds + * (`start.go:1150`) — the per-enabled-Edge-Function module bind mounts Go + * resolves from each `supabase/functions/`'s deploy config. Out of + * scope for this port (a separate, large functions-deploy-config-parsing + * concern); pass `[]` until a future edge-functions-serve integration + * supplies these. + */ + readonly functionBinds: ReadonlyArray; + /** Every value {@link legacyBuildStudioEnv} needs, minus the path this builder derives itself. */ + readonly env: Omit; +} + +/** + * Assembles Studio's {@link LegacyStartContainerSpec}, including the snippets + * bind mount and its Docker-path form Go derives from `workdir` once + * (`start.go:1150-1159`) and reuses for both the bind and + * {@link legacyBuildStudioEnv}'s `SNIPPETS_MANAGEMENT_FOLDER`. + */ +export function legacyBuildStudioContainerSpec( + input: LegacyStudioContainerInput, +): LegacyStartContainerSpec { + const hostSnippetsPath = join(input.env.workdir, "supabase", "snippets"); + const containerSnippetsPath = legacyToDockerPath(hostSnippetsPath); + + // Go's `utils.RemoveDuplicates` (`start.go:1159`) — order-preserving dedup; + // `Set` iteration order matches Go's own first-seen-wins semantics. + const binds = Array.from( + new Set([...input.functionBinds, `${hostSnippetsPath}:${containerSnippetsPath}:rw`]), + ); + + return { + image: input.image, + containerName: input.containerName, + env: legacyBuildStudioEnv({ ...input.env, containerSnippetsPath }), + binds, + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:${STUDIO_CONTAINER_PORT}/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + ports: [{ hostPort: String(input.port), containerPort: String(STUDIO_CONTAINER_PORT) }], + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: STUDIO_NETWORK_ALIASES, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts new file mode 100644 index 0000000000..c3a87ea728 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildStudioContainerSpec, + legacyBuildStudioEnv, + type LegacyBuildStudioEnvInput, +} from "./studio.service.ts"; + +// Mirrors the fixture Go's `TestBuildStudioEnv` builds via `config.NewConfig()` +// plus its explicit field overrides (`apps/cli-go/internal/start/start_test.go:522-564`), +// translated into this pure function's explicit input shape. +const baseEnvInput: LegacyBuildStudioEnvInput = { + dbPassword: "postgres", + workdir: "/project", + containerSnippetsPath: "/project/supabase/.temp/snippets", + cliVersion: "test-version", + pgMetaContainerName: "test-pgmeta", + kongContainerName: "test-kong", + logflareContainerName: "test-logflare", + studioApiUrl: "http://127.0.0.1:54321", + jwtSecret: "jwt-secret", + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + s3AccessKeyId: "s3-access-key", + s3SecretAccessKey: "s3-secret-key", + openaiApiKey: undefined, + apiSchemas: ["public", "graphql_public"], + apiExtraSearchPath: ["public", "extensions"], + apiMaxRows: 1000, + analyticsEnabled: true, + analyticsBackend: "postgres", +}; + +describe("legacyBuildStudioEnv", () => { + test("mirrors Go's TestBuildStudioEnv fixture", () => { + const env = legacyBuildStudioEnv(baseEnvInput); + + // The exact 8 assertions `TestBuildStudioEnv` makes. + expect(env["SUPABASE_ANON_KEY"]).toBe("anon-key"); + expect(env["SUPABASE_SERVICE_KEY"]).toBe("service-role-key"); + expect(env["SUPABASE_PUBLISHABLE_KEY"]).toBe("sb_publishable_test"); + expect(env["SUPABASE_SECRET_KEY"]).toBe("sb_secret_test"); + expect(env["S3_PROTOCOL_ACCESS_KEY_ID"]).toBe("s3-access-key"); + expect(env["S3_PROTOCOL_ACCESS_KEY_SECRET"]).toBe("s3-secret-key"); + expect(env["SUPABASE_URL"]).toBe("http://test-kong:8000"); + expect(env["STUDIO_PG_META_URL"]).toBe("http://test-pgmeta:8080"); + + // Every other key `buildStudioEnv` emits (`start.go:1319-1347`), not + // asserted by the Go test but covered here for full parity. + expect(env).toEqual({ + CURRENT_CLI_VERSION: "test-version", + STUDIO_PG_META_URL: "http://test-pgmeta:8080", + POSTGRES_PASSWORD: "postgres", + SUPABASE_URL: "http://test-kong:8000", + SUPABASE_PUBLIC_URL: "http://127.0.0.1:54321", + AUTH_JWT_SECRET: "jwt-secret", + SUPABASE_ANON_KEY: "anon-key", + SUPABASE_SERVICE_KEY: "service-role-key", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + SUPABASE_SECRET_KEY: "sb_secret_test", + S3_PROTOCOL_ACCESS_KEY_ID: "s3-access-key", + S3_PROTOCOL_ACCESS_KEY_SECRET: "s3-secret-key", + LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", + OPENAI_API_KEY: "", + PGRST_DB_SCHEMAS: "public,graphql_public", + PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", + PGRST_DB_MAX_ROWS: "1000", + LOGFLARE_URL: "http://test-logflare:4000", + NEXT_PUBLIC_ENABLE_LOGS: "true", + NEXT_ANALYTICS_BACKEND_PROVIDER: "postgres", + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: "/project/supabase/functions", + SNIPPETS_MANAGEMENT_FOLDER: "/project/supabase/.temp/snippets", + HOSTNAME: "0.0.0.0", + POSTGRES_USER_READ_WRITE: "postgres", + }); + }); + + test("LOGFLARE_PRIVATE_ACCESS_TOKEN is always Go's hardcoded 'api-key', regardless of input", () => { + // `analytics.api_key` isn't a `config.toml`-configurable field in Go + // (`toml:"-"`) — there is no input field for it at all. + const env = legacyBuildStudioEnv(baseEnvInput); + expect(env["LOGFLARE_PRIVATE_ACCESS_TOKEN"]).toBe("api-key"); + }); + + test("falls back OPENAI_API_KEY to an empty string when unset", () => { + const env = legacyBuildStudioEnv({ ...baseEnvInput, openaiApiKey: undefined }); + expect(env["OPENAI_API_KEY"]).toBe(""); + }); + + test("passes through a configured OPENAI_API_KEY", () => { + const env = legacyBuildStudioEnv({ ...baseEnvInput, openaiApiKey: "sk-test" }); + expect(env["OPENAI_API_KEY"]).toBe("sk-test"); + }); + + test('reflects analyticsEnabled/analyticsBackend verbatim (Go\'s fmt.Sprintf("%v", ...))', () => { + const env = legacyBuildStudioEnv({ + ...baseEnvInput, + analyticsEnabled: false, + analyticsBackend: "bigquery", + }); + expect(env["NEXT_PUBLIC_ENABLE_LOGS"]).toBe("false"); + expect(env["NEXT_ANALYTICS_BACKEND_PROVIDER"]).toBe("bigquery"); + }); + + test("EDGE_FUNCTIONS_MANAGEMENT_FOLDER is workdir/supabase/functions in Docker-path form", () => { + const env = legacyBuildStudioEnv({ + ...baseEnvInput, + workdir: "/Users/me/my-project", + }); + expect(env["EDGE_FUNCTIONS_MANAGEMENT_FOLDER"]).toBe("/Users/me/my-project/supabase/functions"); + }); +}); + +describe("legacyBuildStudioContainerSpec", () => { + const baseSpecInput = { + image: "supabase/studio:2026.07.07-sha-a6a04f2", + containerName: "supabase_studio_proj", + networkId: "supabase_network_proj", + port: 54323, + functionBinds: [] as ReadonlyArray, + env: baseEnvInput, + }; + + test("assembles the full container spec, wiring pg-meta's own container name into STUDIO_PG_META_URL", () => { + const spec = legacyBuildStudioContainerSpec(baseSpecInput); + + expect(spec.image).toBe("supabase/studio:2026.07.07-sha-a6a04f2"); + expect(spec.containerName).toBe("supabase_studio_proj"); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.networkAliases).toEqual(["studio"]); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + expect(spec.ports).toEqual([{ hostPort: "54323", containerPort: "3000" }]); + expect(spec.healthcheck).toEqual({ + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:3000/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + + // pg-meta URL wiring: a distinct `pgMetaContainerName` (Go's `utils.PgmetaId`, + // resolved by the caller via `legacyServiceContainerName("pg_meta", projectId)`) + // flows through to STUDIO_PG_META_URL exactly like it does in `legacyBuildStudioEnv`. + expect(spec.env["STUDIO_PG_META_URL"]).toBe("http://test-pgmeta:8080"); + }); + + test("derives the snippets bind from env.workdir and includes it alongside functionBinds", () => { + const spec = legacyBuildStudioContainerSpec({ + ...baseSpecInput, + functionBinds: ["/project/supabase/functions/hello:/home/deno/functions/hello:ro"], + }); + + expect(spec.binds).toEqual([ + "/project/supabase/functions/hello:/home/deno/functions/hello:ro", + "/project/supabase/snippets:/project/supabase/snippets:rw", + ]); + // The snippets bind's container-side path also backs SNIPPETS_MANAGEMENT_FOLDER. + expect(spec.env["SNIPPETS_MANAGEMENT_FOLDER"]).toBe("/project/supabase/snippets"); + }); + + test("dedupes the snippets bind against an identical functionBinds entry (Go's utils.RemoveDuplicates)", () => { + const spec = legacyBuildStudioContainerSpec({ + ...baseSpecInput, + functionBinds: ["/project/supabase/snippets:/project/supabase/snippets:rw"], + }); + + expect(spec.binds).toEqual(["/project/supabase/snippets:/project/supabase/snippets:rw"]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts new file mode 100644 index 0000000000..43724b538a --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts @@ -0,0 +1,208 @@ +/** + * Port of Go's "Start pooler" block (`apps/cli-go/internal/start/start.go: + * 1193-1268`), gated on `config.db.pooler.enabled` — the gate itself is + * `start.handler.ts`'s job (a later task), not this module's; this file only + * builds the `docker create` spec (plus the pure tenant-provisioning script it + * embeds — see below). + * + * IMPORTANT — how Go actually provisions the Supavisor tenant: it is NOT a + * post-start `docker exec`. Go renders `pooler.exs` (via + * `legacyRenderStartPoolerExs`, already ported in `../lib/template-render.ts`) + * BEFORE the container is created, then bakes the rendered script directly + * into the container's own startup `Cmd` + * (`/bin/sh -c "/app/bin/migrate && /app/bin/supavisor eval '