Files
cfw-autumn/scripts/dw/helpers/neon.ts
2026-06-11 11:44:27 +01:00

180 lines
4.6 KiB
TypeScript

import { sh, fatal, log } from "./shell.ts";
import {
NEON_PROJECT_ID,
NEON_TEMPLATE_BRANCH,
NEON_PARENT_BRANCH,
BRANCH_NAME_RE,
} from "../constants.ts";
import type { NeonBranch } from "../types.ts";
function neon(args: string[]): { stdout: string; stderr: string; code: number } {
return sh("neon", args);
}
export function listBranches(): NeonBranch[] {
const res = neon([
"branches",
"list",
"--project-id",
NEON_PROJECT_ID,
"--output",
"json",
]);
if (res.code !== 0) {
fatal(`neon branches list failed: ${res.stderr || res.stdout}`);
}
try {
return JSON.parse(res.stdout) as NeonBranch[];
} catch {
fatal(`could not parse neon branches list output:\n${res.stdout}`);
}
}
export function findBranchByName(name: string): NeonBranch | undefined {
return listBranches().find((b) => b.name === name);
}
export function createBranch(name: string, parent: string): NeonBranch {
if (!BRANCH_NAME_RE.test(name) && name !== NEON_TEMPLATE_BRANCH) {
fatal(`refusing to create branch with unexpected name: ${name}`);
}
log(`creating neon branch ${name} (parent: ${parent})`);
const res = neon([
"branches",
"create",
"--project-id",
NEON_PROJECT_ID,
"--name",
name,
"--parent",
parent,
"--output",
"json",
]);
if (res.code !== 0) {
fatal(`neon branches create failed: ${res.stderr || res.stdout}`);
}
try {
const parsed = JSON.parse(res.stdout) as { branch?: NeonBranch };
const branch = parsed.branch ?? (parsed as unknown as NeonBranch);
if (!branch?.id) fatal(`unexpected neon create output:\n${res.stdout}`);
return branch;
} catch {
fatal(`could not parse neon create output:\n${res.stdout}`);
}
}
export function deleteBranch(idOrName: string): void {
const res = neon([
"branches",
"delete",
idOrName,
"--project-id",
NEON_PROJECT_ID,
]);
if (res.code !== 0) {
console.error(
`[dw] neon branches delete ${idOrName} failed: ${res.stderr || res.stdout}`,
);
} else {
log(`deleted neon branch ${idOrName}`);
}
}
export function connectionString(
branchName: string,
opts: { pooled?: boolean } = {},
): string {
const args = [
"connection-string",
branchName,
"--project-id",
NEON_PROJECT_ID,
];
if (opts.pooled) args.push("--pooled");
const res = neon(args);
if (res.code !== 0) {
fatal(
`neon connection-string for ${branchName} failed: ${res.stderr || res.stdout}`,
);
}
return res.stdout.trim();
}
// Leaf's chat-sdk connects to a separate `chat` database on the same branch
// (env.ts rewrites DATABASE_URL's path to /chat). Create it via the Neon control
// plane (no CREATE DATABASE privilege / pooler issues), owned by the same role as
// neondb so leaf can connect. Idempotent + non-fatal.
export function ensureChatDatabase(branchName: string): void {
const list = neon([
"databases",
"list",
"--project-id",
NEON_PROJECT_ID,
"--branch",
branchName,
"--output",
"json",
]);
if (list.code !== 0) {
console.error(
`[dw] neon databases list for ${branchName} failed: ${list.stderr || list.stdout}`,
);
return;
}
let databases: { name: string; owner_name: string }[];
try {
databases = JSON.parse(list.stdout) as {
name: string;
owner_name: string;
}[];
} catch {
console.error(`[dw] could not parse neon databases list for ${branchName}`);
return;
}
if (databases.some((db) => db.name === "chat")) {
log(`chat database already present on ${branchName}`);
return;
}
const owner =
databases.find((db) => db.name === "neondb")?.owner_name ??
databases[0]?.owner_name ??
"neondb_owner";
log(`creating chat database on ${branchName} (owner ${owner})`);
const create = neon([
"databases",
"create",
"--project-id",
NEON_PROJECT_ID,
"--branch",
branchName,
"--name",
"chat",
"--owner-name",
owner,
]);
if (create.code !== 0) {
console.error(
`[dw] neon databases create chat on ${branchName} failed: ${create.stderr || create.stdout}`,
);
return;
}
log(`chat database created on ${branchName}`);
}
export function ensureTemplateBranch(): void {
const branch = findBranchByName(NEON_TEMPLATE_BRANCH);
if (branch) return;
log(`bootstrap: ${NEON_TEMPLATE_BRANCH} missing, creating empty parent`);
createBranch(NEON_TEMPLATE_BRANCH, NEON_PARENT_BRANCH);
// Wipe the inherited schema so children start truly empty.
const url = connectionString(NEON_TEMPLATE_BRANCH);
const reset = sh("psql", [url, "-v", "ON_ERROR_STOP=1"], {
stdin: `DROP SCHEMA IF EXISTS public CASCADE;\nCREATE SCHEMA public;\nCREATE EXTENSION IF NOT EXISTS pg_trgm;\n`,
});
if (reset.code !== 0) {
fatal(`failed to reset ${NEON_TEMPLATE_BRANCH}:\n${reset.stderr}`);
}
log(`${NEON_TEMPLATE_BRANCH} ready (empty + pg_trgm)`);
}