diff --git a/src/remote/remote-controller.ts b/src/remote/remote-controller.ts index 907156c0..8db456eb 100644 --- a/src/remote/remote-controller.ts +++ b/src/remote/remote-controller.ts @@ -12,29 +12,7 @@ import { import { ZodError } from "zod"; import { CombinedExport, ExportedStats, Statistics } from "@query-doctor/core"; import { type Connectable } from "../sync/connectable.ts"; -import { connectToSource } from "../sql/postgresjs.ts"; -async function resolveDockerHost(db: Connectable): Promise { - if (!db.isLocalhost()) { - return db; - } - const dockerDb = db.escapeDocker(); - if (dockerDb.url.hostname === db.url.hostname) { - return db; - } - const pg = connectToSource(dockerDb); - try { - await pg.exec("SELECT 1"); - log.info(`Resolved localhost to ${dockerDb.url.hostname} for docker escape`, "remote-controller"); - return dockerDb; - } catch { - log.info(`${dockerDb.url.hostname} unreachable, falling back to ${db.url.hostname}`, "remote-controller"); - return db; - } finally { - // @ts-expect-error | close is added in wrapPgPool - await pg.close(); - } -} const SyncStatus = { NOT_STARTED: "notStarted", @@ -158,7 +136,7 @@ export class RemoteController { let resolvedDb: Connectable; try { this.sendSyncLog("Reaching out to database..."); - resolvedDb = await resolveDockerHost(db); + resolvedDb = await db.resolveDockerHost(); this.lastSourceDb = resolvedDb; this.sendSyncLog(`Connected to ${resolvedDb.toString()}`); } catch (error) { @@ -264,6 +242,29 @@ export class RemoteController { } } + async onInstallPgStatStatements(rawBody: string): Promise { + const body = RemoteSyncRequest.safeDecode(rawBody); + if (!body.success) { + return { status: 400, body: body.error }; + } + + try { + const result = await this.remote.installPgStatStatements(body.data.db); + return { status: 200, body: { success: true, preloadUpdated: result.preloadUpdated } }; + } catch (error) { + console.error(error); + if (error instanceof errors.PostgresError) { + return { status: error.statusCode, body: error.toJSON() }; + } + return { + status: 500, + body: { + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + } + async onReset(rawBody: string): Promise { const body = RemoteSyncRequest.safeDecode(rawBody); if (!body.success) { diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 9599f13b..ee6538fb 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -368,6 +368,11 @@ export class Remote extends EventEmitter { this.optimizer.restart({ clearQueries: true }); } + async installPgStatStatements(source: Connectable): Promise<{ preloadUpdated: boolean }> { + const connector = this.sourceManager.getConnectorFor(source); + return connector.installPgStatStatements(); + } + /** * Process a successful sync and run any potential cleanup functions */ diff --git a/src/server/http.ts b/src/server/http.ts index a518b4fe..8618ef68 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -166,6 +166,22 @@ export async function createServer( return reply.status(result.status).send(result.body); }); + fastify.post("/postgres/extensions/pg_stat_statements", async (request, reply) => { + log.info(`[POST] /postgres/extensions/pg_stat_statements`, "http"); + const body = RemoteSyncRequest.safeDecode(JSON.stringify(request.body)); + if (!body.success) { + return reply.status(400).send(body.error); + } + try { + const db = await body.data.db.resolveDockerHost(); + const connector = sourceConnectionManager.getConnectorFor(db); + const result = await connector.installPgStatStatements(); + return reply.status(200).send({ success: true, ...result }); + } catch (error) { + return reply.status(500).send(makeUnexpectedErrorResult(error).body); + } + }); + fastify.post("/postgres/live", async (request, reply) => { log.info(`[POST] /postgres/live`, "http"); const result = await onSyncLiveQuery(request.body); diff --git a/src/sync/connectable.ts b/src/sync/connectable.ts index 66ac86cf..cba2bb4e 100644 --- a/src/sync/connectable.ts +++ b/src/sync/connectable.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { env } from "../env.ts"; import { PgIdentifier } from "@query-doctor/core"; +import { connectToSource } from "../sql/postgresjs.ts"; +import { log } from "../log.ts"; /** * Represents a valid connection to a database. @@ -130,6 +132,28 @@ export class Connectable { ); } + async resolveDockerHost(): Promise { + if (!this.isLocalhost()) { + return this; + } + const dockerDb = this.escapeDocker(); + if (dockerDb.url.hostname === this.url.hostname) { + return this; + } + const pg = connectToSource(dockerDb); + try { + await pg.exec("SELECT 1"); + log.info(`Resolved localhost to ${dockerDb.url.hostname} for docker escape`, "connectable"); + return dockerDb; + } catch { + log.info(`${dockerDb.url.hostname} unreachable, falling back to ${this.url.hostname}`, "connectable"); + return this; + } finally { + // @ts-expect-error | close is added in wrapPgPool + await pg.close(); + } + } + toString() { return this.url.toString(); } diff --git a/src/sync/pg-connector.ts b/src/sync/pg-connector.ts index e44255dc..efadd5cb 100644 --- a/src/sync/pg-connector.ts +++ b/src/sync/pg-connector.ts @@ -596,6 +596,46 @@ ORDER BY } } + public async installPgStatStatements(): Promise<{ preloadUpdated: boolean }> { + let preloadUpdated = false; + + const [preload] = await this.db.exec<{ setting: string }>(` + SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'; -- @qd_introspection + `); + const current = preload?.setting ?? ""; + const libs = current.split(",").map((s) => s.trim()).filter(Boolean); + if (!libs.includes("pg_stat_statements")) { + const updated = [...libs, "pg_stat_statements"].join(","); + try { + await this.db.exec(`ALTER SYSTEM SET shared_preload_libraries = '${updated}';`); + preloadUpdated = true; + } catch (err) { + throw new PostgresError(err instanceof Error ? err.message : String(err)); + } + } + + const [result] = await this.db.exec<{ exists: boolean }>(` + SELECT EXISTS( + SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements' + ) AS exists; -- @qd_introspection + `); + if (!result?.exists) { + try { + await this.db.exec(`CREATE EXTENSION pg_stat_statements;`); + } catch (err) { + throw new PostgresError(err instanceof Error ? err.message : String(err)); + } + } + + try { + await this.db.exec(`SELECT 1 FROM pg_stat_statements LIMIT 1; -- @qd_introspection`); + } catch (err) { + throw new PostgresError(err instanceof Error ? err.message : String(err)); + } + + return { preloadUpdated }; + } + public async checkPrivilege(): Promise<{ username: string; isSuperuser: boolean;