feat(cli): ClickHouse Managed Postgres provider
What
clickhouse becomes a known provider in the CLI, for ClickHouse Managed Postgres (public beta on AWS).
prepare-db/unprepare-dbdetect the provider from the host (*.pg.clickhouse.cloud) and printProvider: clickhouse (detected from host). An explicit--provideralways wins.- ClickHouse hands the customer a real superuser, so the plan is the self-managed one:
postgres_ai_mongets what that plan grants: role,pg_stat_statements,pg_monitor,pg_read_all_stats, select onpg_catalog.pg_index, thepostgres_aischema (viewpg_statistic, SECURITY DEFINERtable_describe), usage onpublic, a persistentsearch_path, and with optional permissionspg_ls_dir/pg_stat_file(plusrds_tools.pg_ls_multixactdirwhere it exists). - Before the first query,
prepare-dbprints one-- scope:line derived from the plan steps about to run (describeInitScope), listing every grant above; it shrinks under--skip-optional-permissionsand is omitted on--reset-password, which grants nothing. Under--jsonboth notes go to stderr, so stdout stays valid JSON. channel_binding=requirein a URI (ClickHouse's console hands out such URLs) now enables SCRAM channel binding in node-postgres. Before, the parameter was silently ignored. Other values are stripped.
Tests
cli/test/clickhouse-provider.test.ts: provider detection table (including look-alike hosts), the plan equals the self-managed one, the scope line comes before the SQL, and channel binding in both connection paths.cli/test/clickhouse-provider.integration.test.tsand the CI jobcli:clickhouse-like:tests: stockpostgres:17andpostgres:18with onlypg_stat_statementspreloaded. That matches ClickHouse's offering, which has nopg_stat_kcache,pg_wait_samplingorauto_explain. The job runsprepare-db, then--verify, then checks the role is not superuser but can readpg_stat_statements.--jsonkeeps stdout pure JSON, with the scope line on stderr. Aspostgres_ai_mon, it runs the SQL of every metric in the pgwatchfullpreset (50 Prometheus-sink and 2 Postgres-sink queries, each picked for the server major). Failures are frozen inclickhouse-like-collector-failures.golden.json, which is[], so the collectors work unchanged on this extension set. It ends withunprepare-db.- Red first (
67cbe35,6b9b27b), then green (7d8f5b5,a877ce6). Locally: 30/30 unit tests, 6/6 integration tests on PG 17 and on PG 18. - Real cloud (ClickHouse, r6gd.medium PG17, created and deleted in about 7 min): the provider was detected from the host, the scope line was printed, and
prepare-dband--verifypassed as the service's superuser. - Full-stack acceptance on real ClickHouse Cloud, 2026-09-28, together with the stacked !436 (closed): we ran the monitoring stack locally against one r6gd.medium PG17 service (created 07:52 UTC, deleted 08:16 UTC; the org's service list is empty afterwards). 16 min of collection: all 21 host-metric families arrived, pgwatch stored 213 metric names, and the reporter built 25 checks with no errors. D004 reports pg_stat_kcache unavailable and degrades cleanly. The run found two bugs (
mon targets addrejectedpostgres://; pgwatch failed onchannel_binding=require); both are fixed red/green in !436 (closed).
Not in this MR
- Host metrics from the ClickHouse Cloud Prometheus endpoint: stacked MR.
Relates to https://gitlab.com/postgres-ai/internal/-/issues/354
Review round 1 (5a3b6c6a)
- Blocking: scope line now derived from the plan (see above).
channel_binding=require: parsed from conninfo too; disables the plaintext retry; rejected withsslmode=disable. The negotiated mechanism is still driver-preferred, not enforced (documented in README).- Detection also from
--host/PGHOST, trailing-dot FQDN tolerated. --verify --jsonemitsprovideronly when explicit or detected.- Process-level tests for the detected branch (prepare-db and unprepare-db, plain and
--json),--skip-optional-permissions,--reset-password; CI sentinel for the DB URL; vacuous assertion dropped. - README "ClickHouse Managed Postgres" section, CHANGELOG entry, JSDoc.
- Not changed: commit subjects in the pushed history, and the
.cursorsubmodule bump.
Executed on postgres:17 with pg_stat_statements (unit 47/47, integration 7/7):
Round 2 (c20bfef4): table_describe cannot be hijacked through public
On ClickHouse the owner of postgres_ai.table_describe is a real superuser, and the function had search_path = pg_catalog, public. Any role that can create in public could add an exact-type overload that outranks the built-in and runs as the owner. We reproduced this on PG 17: a public.format(text, text, text) created by postgres_ai_mon (after a grant create on schema public) made table_describe fail with "hijacked as postgres".
search_pathis nowpg_catalog, pg_temp. Unqualified names keep their old meaning (pg_catalog, then public) through a catalog lookup; qualified names still go throughregclass. We kept SECURITY DEFINER becauseregclassneeds USAGE on the schema, which the monitoring role does not have on customer schemas.- A cross-model review of that change found that PG 18 stores NOT NULL constraints as
contype 'n', which hittext || "char"("operator is not unique"). Fixed with a cast. Unqualified names are compared asname, so an overlong name is truncated the wayregclasstruncates it. - Test:
table_describe cannot be hijacked through objects in publicinclickhouse-provider.integration.test.ts. It creates overloads ofarray_appendandformatinpublicas the monitoring role, then describes a public table, a table in another schema,pg_class, and a missing name (42P01). - Red first (
ac3b5c0: hijacked on 17, operator error on 18), then green (375d3a7,c20bfef). Integration 7/7 onpostgres:17and onpostgres:18. - Real ClickHouse (r6gd.medium PG 17, 2026-09-30): the admin is
rolsuper = true,proconfigissearch_path=pg_catalog, pg_temp, and with apublic.format(text, text, text)overload present,table_describe('acc_probe')returnedTable: public.acc_probe.
Behaviour changes: unqualified temp tables are no longer found (use pg_temp.name), public tables print schema-qualified in "Referenced by", and malformed identifiers raise 22023 instead of 42602.
What we did not check
- Callers that relied on the old output format of "Referenced by".
- The Supabase copy of this function in platform-all (!541) is a separate change.
Round 3 (review of 2026-09-29, round 2)
table_describeis SECURITY INVOKER, as in the platform generator (platform-all !541). It reads only the world-readable catalog, so it needs nothing the caller lacks. Qualified names now resolve throughpg_class/pg_namespaceinstead ofregclass, which demands USAGE on the schema; that was the only reason round 2 kept SECURITY DEFINER.search_pathstayspg_catalog, pg_temp. Namesregclassaccepted keep working:pg_temp.<name>(the caller's temp schema) and<current database>.<schema>.<name>. The scope line now saysexecute on postgres_ai.table_describe (SECURITY INVOKER, catalog only).- One
channel_bindingparser.splitChannelBindingandrequireChannelBinding(cli/lib/instances.ts) replace the two URL parses and the duplicated "needs TLS" error ininit.tsandinstances.ts; !436 (closed) uses the same helper for the collector URL. - Tests:
clickhouse-provider.integration.test.tsassertsprosecdef = falseandproconfig = {search_path=pg_catalog, pg_temp}, describes a table in a schema the monitoring role has no USAGE on, a temp table, a database-qualified name, and still resistspublicoverloads. Redb4deef4,b5927c8; green8d0b2bd,5738637. 7/7 on stockpostgres:15,17and18; CI'scli:clickhouse-like:testsmatrix now includes 15. - Real ClickHouse Managed Postgres (r6gd.medium, PostgreSQL 17.11, 2026-09-30):
prepare-dbfrom this branch exited 0 with the INVOKER scope line; aspostgres_ai_mon,prosecdefwas false,table_describe('r2_app.orders')described a table in a schema it has no USAGE on,table_describe('r2_probe')worked with apublic.format(text, text, text)overload present,pg_classresolved inpg_catalog, and--verifyexited 0. The service was deleted; its API URL returns 404. - Own delta: code +194/−32 (was +183/−27), tests +381 (was +368).
- Not changed:
announceScopeand theresolveProvidernotes (round-2 cut list for this MR).
