postgres: a withdrawn consumer keeps its database; retiring renames, never drops (hq issue 241)

This commit is contained in:
2026-10-05 00:33:12 +02:00
parent bd22b53672
commit 190d711a2a
4 changed files with 132 additions and 6 deletions
+40 -4
View File
@@ -145,14 +145,43 @@ export class PostgresClient {
}
}
/** Drop a database and its owning role, idempotently, after evicting live connections. */
async dropDatabaseAndRole(database: string, role: string): Promise<void> {
/**
* Withdraw a consumer without destroying anything (novox/hq issue 241): its login can no longer log
* in and its open connections are ended, and its database stays exactly as it was, under its own
* name. A consumer that comes back is given the same database — create sets LOGIN again — which is
* what a provider must do when "no longer asked for" turns out to be a moment's misreading.
*/
async lockRole(role: string): Promise<void> {
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role));
if (roles.rows.length === 0) return;
await this.query(`ALTER ROLE ${ident(role)} NOLOGIN`);
await this.query(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = " +
literal(role) + " AND pid <> pg_backend_pid()",
);
}
/**
* Take a database out of service on purpose: rename it aside to `<name>_deleted_<date>` and lock
* its owner. Never a drop — the data stays on the server under the new name until a person
* removes it by hand. Returns the name it now has.
*/
async retireDatabase(database: string, now: Date = new Date()): Promise<string> {
const found = await this.query(
"SELECT pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = " + literal(database),
);
if (found.rows.length === 0) throw new Error(`no database named ${database}`);
const owner = String((found.rows[0] as Record<string, unknown>).owner ?? "");
const aside = retiredName(database, now);
const taken = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(aside));
if (taken.rows.length > 0) throw new Error(`${aside} already exists; retire it by hand first`);
if (owner && owner !== "postgres") await this.query(`ALTER ROLE ${ident(owner)} NOLOGIN`);
await this.query(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = " +
literal(database) + " AND pid <> pg_backend_pid()",
);
await this.query(`DROP DATABASE IF EXISTS ${ident(database)}`);
await this.query(`DROP ROLE IF EXISTS ${ident(role)}`);
await this.query(`ALTER DATABASE ${ident(database)} RENAME TO ${ident(aside)}`);
return aside;
}
/** List the non-template databases, with size, for the postgres_list_databases tool. */
@@ -301,3 +330,10 @@ function parseCsv(text: string): string[][] {
endRecord();
return records;
}
/** The name a retired database is renamed to: `<name>_deleted_<yyyymmdd>`, within postgres's 63 bytes. */
export function retiredName(database: string, now: Date = new Date()): string {
const stamp = now.toISOString().slice(0, 10).replace(/-/g, "");
const suffix = `_deleted_${stamp}`;
return database.slice(0, 63 - suffix.length) + suffix;
}