From 0ea8259517268670baea4866d1abd822d19112f9 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Tue, 10 Feb 2026 22:15:02 +0400 Subject: [PATCH 1/4] feat(remote): propagate pg_stat_statements missing error to frontend When pg_stat_statements is not installed, syncFrom now returns a recentQueriesError instead of silently swallowing the failure. The remote controller uses this to return queries with type: "error" so the frontend can show install instructions. --- src/remote/remote-controller.ts | 10 ++++++++-- src/remote/remote.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/remote/remote-controller.ts b/src/remote/remote-controller.ts index 11d0555..2dc699d 100644 --- a/src/remote/remote-controller.ts +++ b/src/remote/remote-controller.ts @@ -27,6 +27,7 @@ export class RemoteController { private socket?: WebSocket; private syncResponse?: Awaited>; private syncStatus: SyncStatus = SyncStatus.NOT_STARTED; + private recentQueriesError?: { type: "extension_not_installed"; extensionName: string }; constructor( private readonly remote: Remote, @@ -128,7 +129,9 @@ export class RemoteController { status: this.syncStatus, meta, schema, - queries: { type: "ok", value: queries }, + queries: this.recentQueriesError + ? { type: "error", error: "extension_not_installed" } + : { type: "ok", value: queries }, disabledIndexes: { type: "ok", value: disabledIndexes }, deltas, }); @@ -147,13 +150,16 @@ export class RemoteController { type: "pullFromSource", }); this.syncStatus = SyncStatus.COMPLETED; + this.recentQueriesError = this.syncResponse.recentQueriesError; const { schema, meta } = this.syncResponse; const queries = this.remote.optimizer.getQueries(); return Response.json({ meta, schema, - queries: { type: "ok", value: queries }, + queries: this.recentQueriesError + ? { type: "error", error: "extension_not_installed" } + : { type: "ok", value: queries }, }); } catch (error) { this.syncStatus = SyncStatus.FAILED; diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 993e568..265b0ed 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -8,6 +8,7 @@ import { import { type Connectable } from "../sync/connectable.ts"; import { DumpCommand, RestoreCommand } from "../sync/schema-link.ts"; import { ConnectionManager } from "../sync/connection-manager.ts"; +import { ExtensionNotInstalledError } from "../sync/errors.ts"; import { type RecentQuery } from "../sql/recent-query.ts"; import { type Op } from "jsondiffpatch/formatters/jsonpatch"; import { type FullSchema } from "../sync/schema_differ.ts"; @@ -82,6 +83,7 @@ export class Remote extends EventEmitter { { meta: { version?: string; inferredStatsStrategy?: InferredStatsStrategy }; schema: RemoteSyncFullSchemaResponse; + recentQueriesError?: { type: "extension_not_installed"; extensionName: string }; } > { await this.resetDatabase(); @@ -120,8 +122,14 @@ export class Remote extends EventEmitter { ); let queries: RecentQuery[] = []; + let recentQueriesError: { type: "extension_not_installed"; extensionName: string } | undefined; if (recentQueries.status === "fulfilled") { queries = recentQueries.value; + } else if (recentQueries.reason instanceof ExtensionNotInstalledError) { + recentQueriesError = { + type: "extension_not_installed", + extensionName: recentQueries.reason.extension, + }; } await this.onSuccessfulSync( @@ -146,6 +154,7 @@ export class Remote extends EventEmitter { ? fullSchema.reason.message : "Unknown error", }, + recentQueriesError, }; } From edc1d80f2252b14b6bf34ae1d0c79fdc8ecb93e0 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Wed, 11 Feb 2026 01:17:36 +0400 Subject: [PATCH 2/4] test(remote): add tests for missing pg_stat_statements error handling Verify that syncFrom returns recentQueriesError when the source database does not have pg_stat_statements installed, and that the controller returns queries: { type: "error", error: "extension_not_installed" }. --- src/remote/remote-controller.test.ts | 73 ++++++++++++++++++++++++++++ src/remote/remote.test.ts | 51 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/src/remote/remote-controller.test.ts b/src/remote/remote-controller.test.ts index c02df93..746f88d 100644 --- a/src/remote/remote-controller.test.ts +++ b/src/remote/remote-controller.test.ts @@ -190,3 +190,76 @@ Deno.test({ } }, }); + +Deno.test({ + name: "controller returns extension error when pg_stat_statements is not installed", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const [sourceDb, targetDb] = await Promise.all([ + new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + create table testing(a int, b text); + insert into testing values (1); + create index on testing(b); + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .start(), + new PostgreSqlContainer("postgres:17").start(), + ]); + const controller = new AbortController(); + + const target = Connectable.fromString( + targetDb.getConnectionUri(), + ); + const source = Connectable.fromString( + sourceDb.getConnectionUri(), + ); + + const sourceOptimizer = ConnectionManager.forLocalDatabase(); + + const remoteInstance = new Remote(target, sourceOptimizer); + const remote = new RemoteController(remoteInstance); + + const server = Deno.serve( + { port: 0, signal: controller.signal }, + async (req: Request): Promise => { + const result = await remote.execute(req); + if (!result) { + throw new Error(); + } + return result; + }, + ); + try { + const response = await fetch( + new Request( + `http://localhost:${server.addr.port}/postgres`, + { + method: "POST", + body: RemoteSyncRequest.encode({ + db: source, + }), + }, + ), + ); + + assertEquals(response.status, 200); + const data = await response.json(); + + // Schema should still sync successfully + assertEquals(data.schema.type, "ok"); + + // Queries should return the extension_not_installed error + assertEquals(data.queries.type, "error"); + assertEquals(data.queries.error, "extension_not_installed"); + } finally { + await remoteInstance.cleanup(); + await Promise.all([sourceDb.stop(), targetDb.stop(), server.shutdown()]); + } + }, +}); diff --git a/src/remote/remote.test.ts b/src/remote/remote.test.ts index b200524..96ea78a 100644 --- a/src/remote/remote.test.ts +++ b/src/remote/remote.test.ts @@ -514,3 +514,54 @@ Deno.test({ } }, }); + +Deno.test({ + name: "returns extension error when pg_stat_statements is not installed", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const [sourceDb, targetDb] = await Promise.all([ + new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + create table testing(a int, b text); + insert into testing values (1); + create index "testing_idx" on testing(b); + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .start(), + testSpawnTarget(), + ]); + + try { + const target = Connectable.fromString(targetDb.getConnectionUri()); + const source = Connectable.fromString(sourceDb.getConnectionUri()); + + await using remote = new Remote( + target, + ConnectionManager.forLocalDatabase(), + ); + + const result = await remote.syncFrom(source); + + // Schema should still sync successfully + assertOk(result.schema); + + const tableNames = result.schema.value.tables.map((table) => + table.tableName.toString() + ); + assertArrayIncludes(tableNames, ["testing"]); + + // Should return the extension error for recent queries + assertEquals(result.recentQueriesError, { + type: "extension_not_installed", + extensionName: "pg_stat_statements", + }); + } finally { + await Promise.all([sourceDb.stop(), targetDb.stop()]); + } + }, +}); From 6d2a24ba0193b706359c26bac8e04b0946bc56f9 Mon Sep 17 00:00:00 2001 From: Xetera Date: Wed, 11 Feb 2026 19:23:35 +0300 Subject: [PATCH 3/4] feat(remote): add `pgStatStatementsStatus` --- src/remote/query-loader.test.ts | 7 ++++--- src/remote/query-loader.ts | 19 ++++++++++++++++--- src/remote/remote-controller.ts | 25 +++++++++++++++++-------- src/remote/remote.ts | 32 +++++++++++++++++++++++++++++--- 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/remote/query-loader.test.ts b/src/remote/query-loader.test.ts index 6e37ae6..0d67720 100644 --- a/src/remote/query-loader.test.ts +++ b/src/remote/query-loader.test.ts @@ -11,6 +11,7 @@ import { Connectable } from "../sync/connectable.ts"; import { ConnectionManager } from "../sync/connection-manager.ts"; import { QueryLoader } from "./query-loader.ts"; import { PostgresConnector } from "../sync/pg-connector.ts"; +import { PostgresError } from "../sync/errors.ts"; function createMockRecentQuery(query: string): RecentQuery { return { @@ -72,7 +73,7 @@ Deno.test({ Deno.test({ name: "QueryLoader - poll handles errors and emits pollError", fn: async () => { - const testError = new Error("Database connection failed"); + const testError = new PostgresError("Database connection failed"); const manager = ConnectionManager.forLocalDatabase(); const connectable = Connectable.fromString( @@ -264,7 +265,7 @@ Deno.test({ using _ = stub(manager, "getConnectorFor", () => ({ getRecentQueries: (): Promise => { - throw "String error"; + throw new PostgresError("String error"); }, } as PostgresConnector)); @@ -296,7 +297,7 @@ Deno.test({ using _ = stub(manager, "getConnectorFor", () => ({ getRecentQueries: (): Promise => { if (shouldFail) { - throw new Error("Unexpected error"); + throw new PostgresError("String error"); } return Promise.resolve([createMockRecentQuery("SELECT 1")]); }, diff --git a/src/remote/query-loader.ts b/src/remote/query-loader.ts index 15f8592..330c72b 100644 --- a/src/remote/query-loader.ts +++ b/src/remote/query-loader.ts @@ -2,10 +2,12 @@ import { EventEmitter } from "node:events"; import { Connectable } from "../sync/connectable.ts"; import { ConnectionManager } from "../sync/connection-manager.ts"; import { RecentQuery } from "../sql/recent-query.ts"; +import { ExtensionNotInstalledError } from "../sync/errors.ts"; export type QueryLoaderEvents = { poll: [RecentQuery[]]; pollError: [unknown]; + pgStatStatementsNotInstalled: []; exit: []; }; @@ -34,10 +36,17 @@ export class QueryLoader extends EventEmitter { await this.runPoll(); this.consecutiveErrors = 0; } catch (error) { - if (error instanceof Error) { - this.emit("pollError", error); + if ( + error instanceof ExtensionNotInstalledError && + error.extension === "pg_stat_statements" + ) { + this.emit("pgStatStatementsNotInstalled"); + // we don't want to increment our consecutive errors + // handler for this one because the user might install it + } else if (error instanceof Error) { + this.consecutiveErrors++; } - this.consecutiveErrors++; + this.emit("pollError", error); } if (this.consecutiveErrors > this.maxErrors) { this.emit("exit"); @@ -79,6 +88,10 @@ export class QueryLoader extends EventEmitter { }, this.interval); } + /** + * @throws {ExtensionNotInstalledError} - pg_stat_statements is not installed + * @throws {PostgresError} - Not regular Error + */ private async runPoll() { const connector = this.sourceManager.getConnectorFor(this.connectable); const queries = await connector.getRecentQueries(); diff --git a/src/remote/remote-controller.ts b/src/remote/remote-controller.ts index 2dc699d..1247db3 100644 --- a/src/remote/remote-controller.ts +++ b/src/remote/remote-controller.ts @@ -27,7 +27,6 @@ export class RemoteController { private socket?: WebSocket; private syncResponse?: Awaited>; private syncStatus: SyncStatus = SyncStatus.NOT_STARTED; - private recentQueriesError?: { type: "extension_not_installed"; extensionName: string }; constructor( private readonly remote: Remote, @@ -118,7 +117,9 @@ export class RemoteController { return Response.json({ status: this.syncStatus }); } const { schema, meta } = this.syncResponse; - const { queries, diffs, disabledIndexes } = await this.remote.getStatus(); + const { queries, diffs, disabledIndexes, pgStatStatementsNotInstalled } = + await this.remote.getStatus(); + let deltas: DeltasResult; if (diffs.status === "fulfilled") { deltas = { type: "ok", value: diffs.value }; @@ -129,14 +130,22 @@ export class RemoteController { status: this.syncStatus, meta, schema, - queries: this.recentQueriesError - ? { type: "error", error: "extension_not_installed" } + queries: pgStatStatementsNotInstalled + ? this.pgStatStatementsNotInstalledError() : { type: "ok", value: queries }, disabledIndexes: { type: "ok", value: disabledIndexes }, deltas, }); } + private pgStatStatementsNotInstalledError() { + return { + type: "error", + error: "extension_not_installed", + extensionName: "pg_stat_statements", + } as const; + } + private async onFullSync(request: Request): Promise { const body = RemoteSyncRequest.safeDecode(await request.text()); if (!body.success) { @@ -150,15 +159,15 @@ export class RemoteController { type: "pullFromSource", }); this.syncStatus = SyncStatus.COMPLETED; - this.recentQueriesError = this.syncResponse.recentQueriesError; const { schema, meta } = this.syncResponse; - const queries = this.remote.optimizer.getQueries(); + const { queries, pgStatStatementsNotInstalled } = await this.remote + .getStatus(); return Response.json({ meta, schema, - queries: this.recentQueriesError - ? { type: "error", error: "extension_not_installed" } + queries: pgStatStatementsNotInstalled + ? this.pgStatStatementsNotInstalledError() : { type: "ok", value: queries }, }); } catch (error) { diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 265b0ed..f29a840 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -61,6 +61,8 @@ export class Remote extends EventEmitter { private isPolling = false; private queryLoader?: QueryLoader; private schemaLoader?: SchemaLoader; + private pgStatStatementsStatus: PgStatStatementsStatus = + PgStatStatementsStatus.Unknown; constructor( /** This has to be a local url. Very bad things will happen if this is a remote URL */ @@ -83,7 +85,10 @@ export class Remote extends EventEmitter { { meta: { version?: string; inferredStatsStrategy?: InferredStatsStrategy }; schema: RemoteSyncFullSchemaResponse; - recentQueriesError?: { type: "extension_not_installed"; extensionName: string }; + recentQueriesError?: { + type: "extension_not_installed"; + extensionName: string; + }; } > { await this.resetDatabase(); @@ -122,7 +127,10 @@ export class Remote extends EventEmitter { ); let queries: RecentQuery[] = []; - let recentQueriesError: { type: "extension_not_installed"; extensionName: string } | undefined; + let recentQueriesError: { + type: "extension_not_installed"; + extensionName: string; + } | undefined; if (recentQueries.status === "fulfilled") { queries = recentQueries.value; } else if (recentQueries.reason instanceof ExtensionNotInstalledError) { @@ -178,7 +186,13 @@ export class Remote extends EventEmitter { }), ]); - return { queries, diffs, disabledIndexes }; + return { + queries, + diffs, + disabledIndexes, + pgStatStatementsNotInstalled: + this.pgStatStatementsStatus === PgStatStatementsStatus.NotInstalled, + }; } /** @@ -346,6 +360,9 @@ export class Remote extends EventEmitter { ); console.error(error); }); + this.pgStatStatementsStatus = PgStatStatementsStatus.Installed; + }).on("pgStatStatementsNotInstalled", () => { + this.pgStatStatementsStatus = PgStatStatementsStatus.NotInstalled; }); this.queryLoader.on("exit", () => { log.error("Query loader exited", "remote"); @@ -381,3 +398,12 @@ type StatsResult = { mode: StatisticsMode; strategy: InferredStatsStrategy; }; + +const PgStatStatementsStatus = { + Installed: "Installed", + NotInstalled: "Not Installed", + Unknown: "Unknown", +} as const; + +type PgStatStatementsStatus = + typeof PgStatStatementsStatus[keyof typeof PgStatStatementsStatus]; From e64ce1a6d9acc6b0827d6dd208ba293e5aadeb5c Mon Sep 17 00:00:00 2001 From: Xetera Date: Wed, 11 Feb 2026 21:14:07 +0300 Subject: [PATCH 4/4] chore(query-optimizer): remove unnecessary test We shouldn't need to differentiate between whether errors are Error or unknown. --- src/remote/query-loader.test.ts | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/remote/query-loader.test.ts b/src/remote/query-loader.test.ts index 0d67720..72b0ad0 100644 --- a/src/remote/query-loader.test.ts +++ b/src/remote/query-loader.test.ts @@ -255,32 +255,6 @@ Deno.test({ }, }); -Deno.test({ - name: "QueryLoader - handles non-Error exceptions", - fn: async () => { - const manager = ConnectionManager.forLocalDatabase(); - const connectable = Connectable.fromString( - "postgres://localhost:5432/test", - ); - - using _ = stub(manager, "getConnectorFor", () => ({ - getRecentQueries: (): Promise => { - throw new PostgresError("String error"); - }, - } as PostgresConnector)); - - const loader = new QueryLoader(manager, connectable, { maxErrors: 1 }); - - const pollErrors: unknown[] = []; - loader.on("pollError", (error) => { - pollErrors.push(error); - }); - - await loader.poll(); - assertEquals(pollErrors.length, 0); - }, -}); - Deno.test({ name: "QueryLoader - emits exit on unexpected promise rejection in scheduled poll",