diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index b20dffd62c..a24552b189 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -350,6 +350,7 @@ export { type WorktreeRemoveResult, } from "./miner/worktree-plan.js"; export * from "./miner/worktree-pool.js"; +export * from "./miner/attempt-db-fork.js"; export { invokeCodingAgentDriver, type AttemptLogSink, diff --git a/packages/loopover-engine/src/miner/attempt-db-fork.ts b/packages/loopover-engine/src/miner/attempt-db-fork.ts new file mode 100644 index 0000000000..6c3d2272bb --- /dev/null +++ b/packages/loopover-engine/src/miner/attempt-db-fork.ts @@ -0,0 +1,209 @@ +// Neon branch-per-attempt disposable DB fork for APR execution (#7858, implements #7649's ratified decision). +// Mirrors worktree-pool.ts's per-attempt code-checkout isolation one level deeper: where that module gives +// each attempt its own git worktree (a filesystem-level fork), this module gives an attempt its own Neon +// branch (a storage-level fork) off the OPERATOR's already-provisioned tenant branch -- never off Neon's +// project-level default branch, and never shared between concurrent attempts. +// +// Self-host scope only (see this issue's own scope note): a bare containerized Node.js process connects to a +// Neon branch over the plain Postgres wire protocol via `connectionString` below -- no Cloudflare Hyperdrive +// binding is used or required. Hyperdrive is an optional, Workers-runtime-specific connection-pooling layer; +// Neon branches are independently connectable with any standard Postgres client regardless of whether one +// exists. The hosted path (control-plane's AmsTenantContainer) has its own separate, still-open blocker before +// ANY database credential can reach a running hosted container at all (#8202) -- unrelated to this module. +// +// Endpoint paths/response shapes below mirror control-plane/src/neon-database-driver.ts's already-reviewed +// pattern (Neon's public v2 API, https://api-docs.neon.tech/reference, as documented at the time this was +// written) -- same caveat that file states applies here too: verify against a live account before the first +// real deploy; the test suite mocks every call, no live Neon credentials are used anywhere in this repo. +import { createHash } from "node:crypto"; + +const DEFAULT_API_BASE_URL = "https://console.neon.tech/api/v2"; +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_OPERATION_POLL_INTERVAL_MS = 500; +const DEFAULT_OPERATION_POLL_TIMEOUT_MS = 30_000; + +export type AttemptDbForkConfig = { + apiKey: string; + projectId: string; + /** The operator's own already-provisioned Neon branch ID to fork attempt branches FROM -- never Neon's + * project-level default branch, so an attempt fork always starts from the operator's real, current tenant + * data, not an unrelated/empty baseline. */ + parentBranchId: string; + /** Override for tests only -- production always uses Neon's real API. */ + apiBaseUrl?: string; + /** Override for tests only -- keeps operation-polling tests fast. */ + operationPollIntervalMs?: number; + operationPollTimeoutMs?: number; +}; + +export type AttemptDbFork = { + branchId: string; + connectionString: string; +}; + +type NeonOperation = { id: string; status: string }; +type NeonBranch = { id: string; name: string }; +type NeonEndpoint = { host: string }; +type NeonRole = { name: string; password?: string }; + +// Neon branch names are case-sensitive and length-limited (63 chars) -- mirrors neon-database-driver.ts's own +// #8026 collision-guard reasoning: only truncate names that actually need it, and suffix a truncated one with +// a short hash of the untruncated name so two long, prefix-similar attempt ids can never collide on the same +// branch name. +const NEON_BRANCH_NAME_MAX_LENGTH = 63; +const NEON_BRANCH_NAME_COLLISION_SUFFIX_LENGTH = 8; + +function sanitizeForBranchName(raw: string): string { + return raw + .toLowerCase() + .replaceAll(/[^a-z0-9_-]+/g, "-") + .replaceAll(/-{2,}/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function branchNameFor(attemptId: string): string { + const sanitized = sanitizeForBranchName(`attempt-${attemptId}`); + if (sanitized.length <= NEON_BRANCH_NAME_MAX_LENGTH) return sanitized; + const suffix = createHash("sha256").update(sanitized).digest("hex").slice(0, NEON_BRANCH_NAME_COLLISION_SUFFIX_LENGTH); + const prefixLength = NEON_BRANCH_NAME_MAX_LENGTH - 1 - suffix.length; + return `${sanitized.slice(0, prefixLength)}-${suffix}`; +} + +/** Every attempt branch's role is named identically to its branch -- one branch, one role, no separate naming + * scheme to keep in sync (same convention as neon-database-driver.ts's tenant-level roleNameFor). */ +function roleNameForBranch(branchName: string): string { + return branchName; +} + +class NeonApiError extends Error { + constructor(method: string, path: string, status: number, body: string) { + super(`Neon API ${method} ${path} failed (${status}): ${body.slice(0, 500)}`); + this.name = "NeonApiError"; + } +} + +async function neonFetch(config: AttemptDbForkConfig, method: string, path: string, body?: unknown): Promise { + const baseUrl = config.apiBaseUrl ?? DEFAULT_API_BASE_URL; + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: { + authorization: `Bearer ${config.apiKey}`, + "content-type": "application/json", + accept: "application/json", + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + const text = await response.text(); + if (!response.ok) throw new NeonApiError(method, path, response.status, text); + return (text ? JSON.parse(text) : undefined) as T; +} + +/** Same async-operation-polling contract as neon-database-driver.ts's identical helper: branch/role mutations + * return pending `operations[]` that must reach `"finished"` before the resource is actually usable. */ +async function waitForOperations(config: AttemptDbForkConfig, operations: readonly NeonOperation[]): Promise { + const intervalMs = config.operationPollIntervalMs ?? DEFAULT_OPERATION_POLL_INTERVAL_MS; + const timeoutMs = config.operationPollTimeoutMs ?? DEFAULT_OPERATION_POLL_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + let pending = operations.filter((operation) => operation.status !== "finished"); + while (pending.length > 0) { + if (Date.now() >= deadline) { + throw new Error(`Neon operation(s) did not finish within ${timeoutMs}ms: ${pending.map((operation) => operation.id).join(", ")}`); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + const refreshed = await Promise.all( + pending.map((operation) => neonFetch<{ operation: NeonOperation }>(config, "GET", `/projects/${config.projectId}/operations/${operation.id}`)), + ); + for (const { operation } of refreshed) { + if (operation.status === "failed") throw new Error(`Neon operation ${operation.id} failed`); + } + pending = refreshed.map(({ operation }) => operation).filter((operation) => operation.status !== "finished"); + } +} + +async function findBranchByName(config: AttemptDbForkConfig, name: string): Promise { + const { branches } = await neonFetch<{ branches: NeonBranch[] }>(config, "GET", `/projects/${config.projectId}/branches`); + return branches.find((branch) => branch.name === name); +} + +/** Create a disposable Neon branch forked off `config.parentBranchId` for exactly one attempt, with its own + * freshly-created role (explicit, not assumed-inherited from the parent -- this repo has no live Neon account + * to verify role-inheritance semantics against, so this mirrors neon-database-driver.ts's own always-explicit + * role-creation pattern rather than relying on unverified copy-on-write assumptions). The database itself + * (and its data) IS inherited from the parent branch via Neon's storage-level branching -- unlike a role, + * Postgres databases are catalog objects that live inside the branched storage itself, so no separate + * database-creation call is made here. + * + * Idempotent on `attemptId`: a retried call for the same attempt finds its already-created branch by name + * (via {@link findBranchByName}) instead of creating a duplicate, mirroring provisionNeonDatabase's own + * existing-branch-reuse path. The attempt's own database name is resolved from the PARENT branch's own + * database list (the branch inherits the same database(s) the parent already has), not re-derived from + * attemptId, since it must match whatever database the coding agent's own connection actually expects. */ +export async function createAttemptDbFork(config: AttemptDbForkConfig, attemptId: string): Promise { + const branchName = branchNameFor(attemptId); + const roleName = roleNameForBranch(branchName); + + const existing = await findBranchByName(config, branchName); + if (existing) { + const { endpoints } = await neonFetch<{ endpoints: NeonEndpoint[] }>(config, "GET", `/projects/${config.projectId}/branches/${existing.id}/endpoints`); + const host = endpoints[0]?.host; + if (!host) throw new Error(`Neon attempt branch ${existing.id} has no compute endpoint`); + const { role } = await neonFetch<{ role: NeonRole }>(config, "GET", `/projects/${config.projectId}/branches/${existing.id}/roles/${roleName}/reveal_password`); + if (!role.password) throw new Error(`Neon role ${roleName} on attempt branch ${existing.id} has no revealable password`); + const databaseName = await parentDatabaseName(config); + return { branchId: existing.id, connectionString: connectionStringFor(host, databaseName, roleName, role.password) }; + } + + const databaseName = await parentDatabaseName(config); + const created = await neonFetch<{ branch: NeonBranch; endpoints: NeonEndpoint[]; operations: NeonOperation[] }>( + config, + "POST", + `/projects/${config.projectId}/branches`, + { branch: { name: branchName, parent_id: config.parentBranchId }, endpoints: [{ type: "read_write" }] }, + ); + await waitForOperations(config, created.operations); + const host = created.endpoints[0]?.host; + if (!host) throw new Error(`Neon attempt branch ${created.branch.id} was created without a compute endpoint`); + + const roleCreated = await neonFetch<{ role: NeonRole; operations: NeonOperation[] }>( + config, + "POST", + `/projects/${config.projectId}/branches/${created.branch.id}/roles`, + { role: { name: roleName } }, + ); + await waitForOperations(config, roleCreated.operations); + if (!roleCreated.role.password) throw new Error(`Neon role ${roleName} was created without a password`); + + return { branchId: created.branch.id, connectionString: connectionStringFor(host, databaseName, roleName, roleCreated.role.password) }; +} + +async function parentDatabaseName(config: AttemptDbForkConfig): Promise { + const { databases } = await neonFetch<{ databases: { name: string }[] }>( + config, + "GET", + `/projects/${config.projectId}/branches/${config.parentBranchId}/databases`, + ); + const database = databases[0]; + if (!database) throw new Error(`Neon parent branch ${config.parentBranchId} has no database to fork`); + return database.name; +} + +function connectionStringFor(host: string, database: string, user: string, password: string): string { + return `postgres://${user}:${password}@${host}:5432/${database}`; +} + +/** Discard an attempt's disposable branch. Idempotent: an attempt whose branch was never created (blocked + * before {@link createAttemptDbFork} ran) or already discarded is a safe no-op. Deleting a Neon branch + * cascades to its role/database/endpoint together -- there is nothing else to clean up separately. Never + * merges the branch's data back into the parent; ratified explicitly by #7649 as a hard requirement, not a + * default that happens to be convenient here. */ +export async function discardAttemptDbFork(config: AttemptDbForkConfig, attemptId: string): Promise { + const branchName = branchNameFor(attemptId); + const existing = await findBranchByName(config, branchName); + if (!existing) return; + + // Tolerates a body-less success response (e.g. 204 No Content) -- some APIs return nothing for a DELETE that + // completed synchronously, with no operation left to poll. + const result = await neonFetch<{ operations?: NeonOperation[] } | undefined>(config, "DELETE", `/projects/${config.projectId}/branches/${existing.id}`); + await waitForOperations(config, result?.operations ?? []); +} diff --git a/test/unit/attempt-db-fork.test.ts b/test/unit/attempt-db-fork.test.ts new file mode 100644 index 0000000000..534fbc3279 --- /dev/null +++ b/test/unit/attempt-db-fork.test.ts @@ -0,0 +1,304 @@ +// Tests for the Neon branch-per-attempt disposable DB fork (#7858). No live Neon account or credentials +// anywhere here -- globalThis.fetch is stubbed with a strict, ordered response queue for every test, mirroring +// control-plane/test/neon-database-driver.test.ts's identical convention (this module deliberately mirrors +// that file's own already-reviewed Neon-calling pattern). +// +// SCOPE NOTE: what these tests CAN verify is that this module makes the correct Neon API calls in the correct +// order/shape, including scoping each attempt to its own distinct branch. What they CANNOT verify -- and what +// no test in this repo can verify without a live Neon account -- is Neon's own platform guarantee that a +// branch's data is actually isolated from its parent and from sibling branches. That isolation guarantee is +// Neon's responsibility, not this module's; this module's responsibility (and this file's actual coverage) is +// requesting the right branch, off the right parent, discarded at the right time. +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createAttemptDbFork, + discardAttemptDbFork, + type AttemptDbForkConfig, +} from "../../packages/loopover-engine/src/miner/attempt-db-fork"; + +const CONFIG: AttemptDbForkConfig = { + apiKey: "neon-test-key", + projectId: "proj-1", + parentBranchId: "br-parent", + operationPollIntervalMs: 1, + operationPollTimeoutMs: 50, +}; + +const ATTEMPT_ID = "JSONbored_loopover-123-1700000000000"; +const BRANCH_NAME = "attempt-jsonbored_loopover-123-1700000000000"; + +let originalFetch: typeof fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +type QueuedResponse = { status?: number; body?: unknown; rawBody?: string }; + +function mockFetchSequence(entries: QueuedResponse[]): { calls: Array<{ url: string; init: RequestInit }> } { + const calls: Array<{ url: string; init: RequestInit }> = []; + let index = 0; + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const entry = entries[index]; + index += 1; + if (!entry) throw new Error(`mockFetchSequence: no queued response for call #${index} (${init.method ?? "GET"} ${url})`); + const text = entry.rawBody ?? JSON.stringify(entry.body); + return new Response(text, { status: entry.status ?? 200 }); + }) as unknown as typeof fetch; + return { calls }; +} + +function bodyOf(init: RequestInit): unknown { + return init.body ? JSON.parse(init.body as string) : undefined; +} + +describe("createAttemptDbFork", () => { + it("fresh create: forks off parentBranchId, inherits the parent's database, creates a fresh role", async () => { + const { calls } = mockFetchSequence([ + { body: { branches: [] } }, // 1. list branches -> not found + { body: { databases: [{ name: "tenant-db" }] } }, // 2. parent's database name + { body: { branch: { id: "br-attempt-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "finished" }] } }, // 3. create branch + { body: { role: { name: BRANCH_NAME, password: "role-pw" }, operations: [{ id: "op-2", status: "finished" }] } }, // 4. create role + ]); + + const fork = await createAttemptDbFork(CONFIG, ATTEMPT_ID); + + expect(fork).toEqual({ + branchId: "br-attempt-1", + connectionString: `postgres://${BRANCH_NAME}:role-pw@ep-1.neon.tech:5432/tenant-db`, + }); + + expect(calls).toHaveLength(4); + expect(calls[0]?.url).toBe("https://console.neon.tech/api/v2/projects/proj-1/branches"); + expect(calls[1]?.url).toBe("https://console.neon.tech/api/v2/projects/proj-1/branches/br-parent/databases"); + expect(calls[2]?.init.method).toBe("POST"); + expect(bodyOf(calls[2]!.init)).toEqual({ branch: { name: BRANCH_NAME, parent_id: "br-parent" }, endpoints: [{ type: "read_write" }] }); + expect(calls[3]?.url).toBe("https://console.neon.tech/api/v2/projects/proj-1/branches/br-attempt-1/roles"); + expect(bodyOf(calls[3]!.init)).toEqual({ role: { name: BRANCH_NAME } }); + expect((calls[2]!.init.headers as Record).authorization).toBe("Bearer neon-test-key"); + }); + + it("polls a not-yet-finished branch-creation operation to completion", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-attempt-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "running" }] } }, + { body: { operation: { id: "op-1", status: "running" } } }, + { body: { operation: { id: "op-1", status: "finished" } } }, + { body: { role: { name: BRANCH_NAME, password: "role-pw" }, operations: [{ id: "op-2", status: "finished" }] } }, + ]); + + const fork = await createAttemptDbFork(CONFIG, ATTEMPT_ID); + expect(fork.branchId).toBe("br-attempt-1"); + }); + + it("falls back to the real default poll interval/timeout when config omits them (exercised via an already-finished operation, so no real 500ms wait occurs)", async () => { + const configWithoutPollOverrides: AttemptDbForkConfig = { apiKey: "neon-test-key", projectId: "proj-1", parentBranchId: "br-parent" }; + mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-attempt-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "finished" }] } }, + { body: { role: { name: BRANCH_NAME, password: "role-pw" }, operations: [{ id: "op-2", status: "finished" }] } }, + ]); + + const fork = await createAttemptDbFork(configWithoutPollOverrides, ATTEMPT_ID); + expect(fork.branchId).toBe("br-attempt-1"); + }); + + it("an operation reaching 'failed' throws", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-attempt-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "running" }] } }, + { body: { operation: { id: "op-1", status: "failed" } } }, + ]); + + await expect(createAttemptDbFork(CONFIG, ATTEMPT_ID)).rejects.toThrow(/Neon operation op-1 failed/); + }); + + it("exceeding the poll timeout throws instead of waiting forever", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-attempt-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "running" }] } }, + ...Array.from({ length: 200 }, () => ({ body: { operation: { id: "op-1", status: "running" } } })), + ]); + + await expect(createAttemptDbFork(CONFIG, ATTEMPT_ID)).rejects.toThrow(/did not finish within \d+ms/); + }); + + it("throws when the parent branch has no database to fork", async () => { + mockFetchSequence([{ body: { branches: [] } }, { body: { databases: [] } }]); + + await expect(createAttemptDbFork(CONFIG, ATTEMPT_ID)).rejects.toThrow(/parent branch br-parent has no database/); + }); + + it("throws when a created branch has no compute endpoint", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-attempt-1", name: BRANCH_NAME }, endpoints: [], operations: [{ id: "op-1", status: "finished" }] } }, + ]); + + await expect(createAttemptDbFork(CONFIG, ATTEMPT_ID)).rejects.toThrow(/created without a compute endpoint/); + }); + + it("throws when the created role has no password", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-attempt-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "finished" }] } }, + { body: { role: { name: BRANCH_NAME }, operations: [{ id: "op-2", status: "finished" }] } }, + ]); + + await expect(createAttemptDbFork(CONFIG, ATTEMPT_ID)).rejects.toThrow(/created without a password/); + }); + + it("a non-ok HTTP response throws a descriptive NeonApiError", async () => { + mockFetchSequence([{ status: 401, body: { message: "invalid api key" } }]); + + await expect(createAttemptDbFork(CONFIG, ATTEMPT_ID)).rejects.toThrow(/Neon API GET .*failed \(401\)/); + }); + + describe("idempotent re-create (retry for the same attemptId)", () => { + it("resolves the already-existing branch instead of creating a duplicate", async () => { + const { calls } = mockFetchSequence([ + { body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, // list -> found + { body: { endpoints: [{ host: "ep-existing.neon.tech" }] } }, // get endpoint + { body: { role: { name: BRANCH_NAME, password: "existing-pw" } } }, // reveal password + { body: { databases: [{ name: "tenant-db" }] } }, // parent's database name + ]); + + const fork = await createAttemptDbFork(CONFIG, ATTEMPT_ID); + + expect(fork).toEqual({ + branchId: "br-existing", + connectionString: `postgres://${BRANCH_NAME}:existing-pw@ep-existing.neon.tech:5432/tenant-db`, + }); + expect(calls).toHaveLength(4); + expect(calls.every((call) => call.init.method === "GET" || call.init.method === undefined)).toBe(true); + expect(calls[2]?.url).toBe(`https://console.neon.tech/api/v2/projects/proj-1/branches/br-existing/roles/${BRANCH_NAME}/reveal_password`); + }); + + it("throws when the existing branch has lost its compute endpoint", async () => { + mockFetchSequence([{ body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, { body: { endpoints: [] } }]); + + await expect(createAttemptDbFork(CONFIG, ATTEMPT_ID)).rejects.toThrow(/has no compute endpoint/); + }); + + it("throws when the existing branch's role has no revealable password", async () => { + mockFetchSequence([ + { body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, + { body: { endpoints: [{ host: "ep-existing.neon.tech" }] } }, + { body: { role: { name: BRANCH_NAME } } }, + ]); + + await expect(createAttemptDbFork(CONFIG, ATTEMPT_ID)).rejects.toThrow(/has no revealable password/); + }); + }); + + it("two different attempt ids are scoped to two DISTINCT branches, each forked off the SAME parentBranchId", async () => { + const { calls: callsA } = mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-a", name: "placeholder" }, endpoints: [{ host: "ep-a.neon.tech" }], operations: [] } }, + { body: { role: { name: "placeholder", password: "pw-a" }, operations: [] } }, + ]); + const forkA = await createAttemptDbFork(CONFIG, "attempt-a"); + + const { calls: callsB } = mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-b", name: "placeholder" }, endpoints: [{ host: "ep-b.neon.tech" }], operations: [] } }, + { body: { role: { name: "placeholder", password: "pw-b" }, operations: [] } }, + ]); + const forkB = await createAttemptDbFork(CONFIG, "attempt-b"); + + expect(forkA.branchId).not.toBe(forkB.branchId); + expect(forkA.connectionString).not.toBe(forkB.connectionString); + const nameA = (bodyOf(callsA[2]!.init) as { branch: { name: string; parent_id: string } }).branch; + const nameB = (bodyOf(callsB[2]!.init) as { branch: { name: string; parent_id: string } }).branch; + expect(nameA.name).not.toBe(nameB.name); + expect(nameA.parent_id).toBe("br-parent"); + expect(nameB.parent_id).toBe("br-parent"); + }); + + // #8026-style regression (see neon-database-driver.ts's own identical guard): two attempt ids sharing a long + // common prefix must not collapse to the same truncated (63-char) branch name, or the second attempt would + // resolve the FIRST attempt's already-existing branch instead of getting its own isolated fork. + it("REGRESSION: two long, prefix-similar attempt ids produce DIFFERENT branch names", async () => { + const longPrefix = "a".repeat(60); + + const { calls: callsA } = mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-a", name: "placeholder" }, endpoints: [{ host: "ep-a.neon.tech" }], operations: [] } }, + { body: { role: { name: "placeholder", password: "pw-a" }, operations: [] } }, + ]); + await createAttemptDbFork(CONFIG, `${longPrefix}-alpha`); + const branchNameA = (bodyOf(callsA[2]!.init) as { branch: { name: string } }).branch.name; + + const { calls: callsB } = mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-b", name: "placeholder" }, endpoints: [{ host: "ep-b.neon.tech" }], operations: [] } }, + { body: { role: { name: "placeholder", password: "pw-b" }, operations: [] } }, + ]); + await createAttemptDbFork(CONFIG, `${longPrefix}-beta`); + const branchNameB = (bodyOf(callsB[2]!.init) as { branch: { name: string } }).branch.name; + + expect(branchNameA).not.toBe(branchNameB); + expect(branchNameA.length).toBeLessThanOrEqual(63); + expect(branchNameB.length).toBeLessThanOrEqual(63); + }); + + it("a short attempt id's branch name is completely unaffected by the collision-suffix logic", async () => { + const { calls } = mockFetchSequence([ + { body: { branches: [] } }, + { body: { databases: [{ name: "tenant-db" }] } }, + { body: { branch: { id: "br-attempt-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [] } }, + { body: { role: { name: BRANCH_NAME, password: "pw" }, operations: [] } }, + ]); + + await createAttemptDbFork(CONFIG, ATTEMPT_ID); + + expect((bodyOf(calls[2]!.init) as { branch: { name: string } }).branch.name).toBe(BRANCH_NAME); + }); +}); + +describe("discardAttemptDbFork", () => { + it("deletes an existing attempt branch, polling the delete operation to completion", async () => { + const { calls } = mockFetchSequence([ + { body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, + { body: { operations: [{ id: "op-4", status: "running" }] } }, + { body: { operation: { id: "op-4", status: "finished" } } }, + ]); + + await discardAttemptDbFork(CONFIG, ATTEMPT_ID); + + expect(calls).toHaveLength(3); + expect(calls[1]?.init.method).toBe("DELETE"); + expect(calls[1]?.url).toBe("https://console.neon.tech/api/v2/projects/proj-1/branches/br-existing"); + }); + + it("tolerates a body-less DELETE response (e.g. 204 No Content) as 'nothing to poll'", async () => { + const { calls } = mockFetchSequence([{ body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, { rawBody: "" }]); + + await discardAttemptDbFork(CONFIG, ATTEMPT_ID); + + expect(calls).toHaveLength(2); + }); + + it("an attempt whose branch was never created (or already discarded) is an idempotent no-op -- no DELETE call", async () => { + const { calls } = mockFetchSequence([{ body: { branches: [] } }]); + + await discardAttemptDbFork(CONFIG, ATTEMPT_ID); + + expect(calls).toHaveLength(1); + }); +});