postgresai 0.14.0-dev.84 → 0.14.0-dev.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1643,9 +1643,9 @@ program
1643
1643
  });
1644
1644
 
1645
1645
  program
1646
- .command("checkup [conn]")
1646
+ .command("checkup [checkIdOrConn] [conn]")
1647
1647
  .description("generate health check reports directly from PostgreSQL (express mode)")
1648
- .option("--check-id <id>", `specific check to run (see list below), or ALL`, "ALL")
1648
+ .option("--check-id <id>", `specific check to run (see list below), or ALL`)
1649
1649
  .option("--node-name <name>", "node name for reports", "node-01")
1650
1650
  .option("--output <path>", "output directory for JSON files")
1651
1651
  .option("--upload", "upload JSON results to PostgresAI (requires API key)")
@@ -1664,13 +1664,43 @@ program
1664
1664
  "",
1665
1665
  "Examples:",
1666
1666
  " postgresai checkup postgresql://user:pass@host:5432/db",
1667
- " postgresai checkup postgresql://user:pass@host:5432/db --check-id D001",
1667
+ " postgresai checkup H002 postgresql://user:pass@host:5432/db",
1668
+ " postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
1668
1669
  " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
1669
- " postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
1670
1670
  " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
1671
1671
  ].join("\n")
1672
1672
  )
1673
- .action(async (conn: string | undefined, opts: CheckupOptions, cmd: Command) => {
1673
+ .action(async (checkIdOrConn: string | undefined, connArg: string | undefined, opts: CheckupOptions, cmd: Command) => {
1674
+ // Support both syntaxes:
1675
+ // pgai checkup postgresql://... -> run ALL checks
1676
+ // pgai checkup H002 postgresql://... -> run specific check (positional)
1677
+ // pgai checkup --check-id H002 postgresql:// -> run specific check (option)
1678
+ const checkIdPattern = /^[A-Z]\d{3}$/i;
1679
+ let conn: string | undefined;
1680
+ let checkId: string;
1681
+
1682
+ if (!checkIdOrConn) {
1683
+ cmd.outputHelp();
1684
+ process.exitCode = 1;
1685
+ return;
1686
+ }
1687
+
1688
+ if (checkIdPattern.test(checkIdOrConn)) {
1689
+ // First arg is a check ID
1690
+ checkId = checkIdOrConn.toUpperCase();
1691
+ conn = connArg;
1692
+ if (!conn) {
1693
+ console.error(`Error: Connection string required when specifying check ID "${checkId}"`);
1694
+ console.error(`\nUsage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname\n`);
1695
+ process.exitCode = 1;
1696
+ return;
1697
+ }
1698
+ } else {
1699
+ // First arg is the connection string
1700
+ conn = checkIdOrConn;
1701
+ checkId = opts.checkId?.toUpperCase() || "ALL";
1702
+ }
1703
+
1674
1704
  if (!conn) {
1675
1705
  cmd.outputHelp();
1676
1706
  process.exitCode = 1;
@@ -1718,12 +1748,11 @@ program
1718
1748
 
1719
1749
  // Generate reports
1720
1750
  let reports: Record<string, any>;
1721
- if (opts.checkId === "ALL") {
1751
+ if (checkId === "ALL") {
1722
1752
  reports = await generateAllReports(client, opts.nodeName, (p) => {
1723
1753
  spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
1724
1754
  });
1725
1755
  } else {
1726
- const checkId = opts.checkId.toUpperCase();
1727
1756
  const generator = REPORT_GENERATORS[checkId];
1728
1757
  if (!generator) {
1729
1758
  spinner.stop();
@@ -1733,7 +1762,7 @@ program
1733
1762
  console.error(`Check ${checkId} (${dictEntry.title}) is not yet available in express mode.`);
1734
1763
  console.error(`Express-mode checks: ${Object.keys(CHECK_INFO).join(", ")}`);
1735
1764
  } else {
1736
- console.error(`Unknown check ID: ${opts.checkId}`);
1765
+ console.error(`Unknown check ID: ${checkId}`);
1737
1766
  console.error(`See 'postgresai checkup --help' for available checks.`);
1738
1767
  }
1739
1768
  process.exitCode = 1;
@@ -13064,7 +13064,7 @@ var {
13064
13064
  // package.json
13065
13065
  var package_default = {
13066
13066
  name: "postgresai",
13067
- version: "0.14.0-dev.84",
13067
+ version: "0.14.0-dev.86",
13068
13068
  description: "postgres_ai CLI",
13069
13069
  license: "Apache-2.0",
13070
13070
  private: false,
@@ -15889,7 +15889,7 @@ var Result = import_lib.default.Result;
15889
15889
  var TypeOverrides = import_lib.default.TypeOverrides;
15890
15890
  var defaults = import_lib.default.defaults;
15891
15891
  // package.json
15892
- var version = "0.14.0-dev.84";
15892
+ var version = "0.14.0-dev.86";
15893
15893
  var package_default2 = {
15894
15894
  name: "postgresai",
15895
15895
  version,
@@ -24971,14 +24971,7 @@ end $$;`;
24971
24971
  });
24972
24972
  let permissionsSql = applyTemplate(loadSqlTemplate("03.permissions.sql"), vars);
24973
24973
  if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
24974
- permissionsSql = permissionsSql.split(`
24975
- `).filter((line) => {
24976
- const trimmed = line.trim();
24977
- if (trimmed.startsWith("--") || trimmed === "")
24978
- return true;
24979
- return !/^\s*alter\s+user\s+/i.test(line);
24980
- }).join(`
24981
- `);
24974
+ permissionsSql = permissionsSql.replace(/-- \[SEARCH_PATH_BLOCK_START\][\s\S]*?-- \[SEARCH_PATH_BLOCK_END\]\n?/, "");
24982
24975
  }
24983
24976
  steps.push({
24984
24977
  name: "03.permissions",
@@ -25162,6 +25155,19 @@ async function verifyInitSetup(params) {
25162
25155
  if (!schemaUsageRes.rows?.[0]?.ok) {
25163
25156
  missingRequired.push("USAGE on schema public");
25164
25157
  }
25158
+ const extSchemaRes = await params.client.query(`
25159
+ select n.nspname as schema
25160
+ from pg_extension e
25161
+ join pg_namespace n on e.extnamespace = n.oid
25162
+ where e.extname = 'pg_stat_statements'
25163
+ `);
25164
+ const extSchema = extSchemaRes.rows?.[0]?.schema;
25165
+ if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
25166
+ const extSchemaUsageRes = await params.client.query("select has_schema_privilege($1, $2, 'USAGE') as ok", [role, extSchema]);
25167
+ if (!extSchemaUsageRes.rows?.[0]?.ok) {
25168
+ missingRequired.push(`USAGE on schema ${extSchema} (pg_stat_statements location)`);
25169
+ }
25170
+ }
25165
25171
  if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
25166
25172
  const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
25167
25173
  const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
@@ -25173,6 +25179,11 @@ async function verifyInitSetup(params) {
25173
25179
  if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
25174
25180
  missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
25175
25181
  }
25182
+ if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
25183
+ if (!sp.includes(extSchema.toLowerCase())) {
25184
+ missingRequired.push(`role search_path includes ${extSchema} (pg_stat_statements location)`);
25185
+ }
25186
+ }
25176
25187
  }
25177
25188
  }
25178
25189
  const explainFnRes = await params.client.query("select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok", [role]);
@@ -27116,7 +27127,7 @@ function buildCheckInfoMap() {
27116
27127
  }
27117
27128
 
27118
27129
  // lib/checkup.ts
27119
- var __dirname = "/builds/postgres-ai/postgres_ai/cli/lib";
27130
+ var __dirname = "/builds/postgres-ai/postgresai/cli/lib";
27120
27131
  var SECONDS_PER_DAY = 86400;
27121
27132
  var SECONDS_PER_HOUR = 3600;
27122
27133
  var SECONDS_PER_MINUTE = 60;
@@ -29487,19 +29498,42 @@ program2.command("unprepare-db [conn]").description("remove monitoring setup: dr
29487
29498
  closeReadline();
29488
29499
  }
29489
29500
  });
29490
- program2.command("checkup [conn]").description("generate health check reports directly from PostgreSQL (express mode)").option("--check-id <id>", `specific check to run (see list below), or ALL`, "ALL").option("--node-name <name>", "node name for reports", "node-01").option("--output <path>", "output directory for JSON files").option("--upload", "upload JSON results to PostgresAI (requires API key)").option("--no-upload", "disable upload to PostgresAI").option("--project <project>", "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)").option("--json", "output JSON to stdout").addHelpText("after", [
29501
+ program2.command("checkup [checkIdOrConn] [conn]").description("generate health check reports directly from PostgreSQL (express mode)").option("--check-id <id>", `specific check to run (see list below), or ALL`).option("--node-name <name>", "node name for reports", "node-01").option("--output <path>", "output directory for JSON files").option("--upload", "upload JSON results to PostgresAI (requires API key)").option("--no-upload", "disable upload to PostgresAI").option("--project <project>", "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)").option("--json", "output JSON to stdout").addHelpText("after", [
29491
29502
  "",
29492
29503
  "Available checks:",
29493
29504
  ...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
29494
29505
  "",
29495
29506
  "Examples:",
29496
29507
  " postgresai checkup postgresql://user:pass@host:5432/db",
29497
- " postgresai checkup postgresql://user:pass@host:5432/db --check-id D001",
29508
+ " postgresai checkup H002 postgresql://user:pass@host:5432/db",
29509
+ " postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
29498
29510
  " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
29499
- " postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
29500
29511
  " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json"
29501
29512
  ].join(`
29502
- `)).action(async (conn, opts, cmd) => {
29513
+ `)).action(async (checkIdOrConn, connArg, opts, cmd) => {
29514
+ const checkIdPattern = /^[A-Z]\d{3}$/i;
29515
+ let conn;
29516
+ let checkId;
29517
+ if (!checkIdOrConn) {
29518
+ cmd.outputHelp();
29519
+ process.exitCode = 1;
29520
+ return;
29521
+ }
29522
+ if (checkIdPattern.test(checkIdOrConn)) {
29523
+ checkId = checkIdOrConn.toUpperCase();
29524
+ conn = connArg;
29525
+ if (!conn) {
29526
+ console.error(`Error: Connection string required when specifying check ID "${checkId}"`);
29527
+ console.error(`
29528
+ Usage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname
29529
+ `);
29530
+ process.exitCode = 1;
29531
+ return;
29532
+ }
29533
+ } else {
29534
+ conn = checkIdOrConn;
29535
+ checkId = opts.checkId?.toUpperCase() || "ALL";
29536
+ }
29503
29537
  if (!conn) {
29504
29538
  cmd.outputHelp();
29505
29539
  process.exitCode = 1;
@@ -29535,12 +29569,11 @@ program2.command("checkup [conn]").description("generate health check reports di
29535
29569
  const connResult = await connectWithSslFallback(Client, adminConn);
29536
29570
  client = connResult.client;
29537
29571
  let reports;
29538
- if (opts.checkId === "ALL") {
29572
+ if (checkId === "ALL") {
29539
29573
  reports = await generateAllReports(client, opts.nodeName, (p) => {
29540
29574
  spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
29541
29575
  });
29542
29576
  } else {
29543
- const checkId = opts.checkId.toUpperCase();
29544
29577
  const generator = REPORT_GENERATORS[checkId];
29545
29578
  if (!generator) {
29546
29579
  spinner.stop();
@@ -29549,7 +29582,7 @@ program2.command("checkup [conn]").description("generate health check reports di
29549
29582
  console.error(`Check ${checkId} (${dictEntry.title}) is not yet available in express mode.`);
29550
29583
  console.error(`Express-mode checks: ${Object.keys(CHECK_INFO).join(", ")}`);
29551
29584
  } else {
29552
- console.error(`Unknown check ID: ${opts.checkId}`);
29585
+ console.error(`Unknown check ID: ${checkId}`);
29553
29586
  console.error(`See 'postgresai checkup --help' for available checks.`);
29554
29587
  }
29555
29588
  process.exitCode = 1;
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
32
32
  -- Hardened clusters sometimes revoke PUBLIC on schema public
33
33
  grant usage on schema public to {{ROLE_IDENT}};
34
34
 
35
- -- Keep search_path predictable; postgres_ai first so our objects are found
36
- alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
35
+ -- Grant access to the schema where pg_stat_statements is installed.
36
+ -- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
37
+ -- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
38
+ do $$
39
+ declare
40
+ ext_schema text;
41
+ begin
42
+ select n.nspname into ext_schema
43
+ from pg_extension e
44
+ join pg_namespace n on e.extnamespace = n.oid
45
+ where e.extname = 'pg_stat_statements';
46
+
47
+ -- Only grant if extension exists and is in a non-standard schema
48
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
49
+ execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
50
+ end if;
51
+ end $$;
52
+
53
+ -- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
54
+ -- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
55
+ do $$
56
+ declare
57
+ ext_schema text;
58
+ sp text;
59
+ begin
60
+ -- Detect pg_stat_statements extension schema
61
+ select n.nspname into ext_schema
62
+ from pg_extension e
63
+ join pg_namespace n on e.extnamespace = n.oid
64
+ where e.extname = 'pg_stat_statements';
65
+
66
+ -- Build search_path: include extension schema if in non-standard location
67
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
68
+ sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
69
+ else
70
+ sp := 'postgres_ai, "$user", public, pg_catalog';
71
+ end if;
72
+
73
+ execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
74
+ end $$;
75
+ -- [SEARCH_PATH_BLOCK_END]
37
76
 
38
77
 
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
32
32
  -- Hardened clusters sometimes revoke PUBLIC on schema public
33
33
  grant usage on schema public to {{ROLE_IDENT}};
34
34
 
35
- -- Keep search_path predictable; postgres_ai first so our objects are found
36
- alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
35
+ -- Grant access to the schema where pg_stat_statements is installed.
36
+ -- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
37
+ -- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
38
+ do $$
39
+ declare
40
+ ext_schema text;
41
+ begin
42
+ select n.nspname into ext_schema
43
+ from pg_extension e
44
+ join pg_namespace n on e.extnamespace = n.oid
45
+ where e.extname = 'pg_stat_statements';
46
+
47
+ -- Only grant if extension exists and is in a non-standard schema
48
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
49
+ execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
50
+ end if;
51
+ end $$;
52
+
53
+ -- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
54
+ -- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
55
+ do $$
56
+ declare
57
+ ext_schema text;
58
+ sp text;
59
+ begin
60
+ -- Detect pg_stat_statements extension schema
61
+ select n.nspname into ext_schema
62
+ from pg_extension e
63
+ join pg_namespace n on e.extnamespace = n.oid
64
+ where e.extname = 'pg_stat_statements';
65
+
66
+ -- Build search_path: include extension schema if in non-standard location
67
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
68
+ sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
69
+ else
70
+ sp := 'postgres_ai, "$user", public, pg_catalog';
71
+ end if;
72
+
73
+ execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
74
+ end $$;
75
+ -- [SEARCH_PATH_BLOCK_END]
37
76
 
38
77
 
package/lib/init.ts CHANGED
@@ -538,16 +538,12 @@ end $$;`;
538
538
  // Some providers restrict ALTER USER - remove those statements.
539
539
  // TODO: Make this more flexible by allowing users to specify which statements to skip via config.
540
540
  if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
541
- permissionsSql = permissionsSql
542
- .split("\n")
543
- .filter((line) => {
544
- const trimmed = line.trim();
545
- // Keep comments and empty lines
546
- if (trimmed.startsWith("--") || trimmed === "") return true;
547
- // Filter out ALTER USER statements (case-insensitive, flexible whitespace)
548
- return !/^\s*alter\s+user\s+/i.test(line);
549
- })
550
- .join("\n");
541
+ // Remove the entire search_path DO block (marked with SEARCH_PATH_BLOCK_START/END)
542
+ // since it contains ALTER USER and can't be line-filtered without breaking the DO block.
543
+ permissionsSql = permissionsSql.replace(
544
+ /-- \[SEARCH_PATH_BLOCK_START\][\s\S]*?-- \[SEARCH_PATH_BLOCK_END\]\n?/,
545
+ ""
546
+ );
551
547
  }
552
548
 
553
549
  steps.push({
@@ -838,6 +834,24 @@ export async function verifyInitSetup(params: {
838
834
  missingRequired.push("USAGE on schema public");
839
835
  }
840
836
 
837
+ // Check access to pg_stat_statements extension schema (may be 'extensions' on Supabase)
838
+ const extSchemaRes = await params.client.query(`
839
+ select n.nspname as schema
840
+ from pg_extension e
841
+ join pg_namespace n on e.extnamespace = n.oid
842
+ where e.extname = 'pg_stat_statements'
843
+ `);
844
+ const extSchema = extSchemaRes.rows?.[0]?.schema;
845
+ if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
846
+ const extSchemaUsageRes = await params.client.query(
847
+ "select has_schema_privilege($1, $2, 'USAGE') as ok",
848
+ [role, extSchema]
849
+ );
850
+ if (!extSchemaUsageRes.rows?.[0]?.ok) {
851
+ missingRequired.push(`USAGE on schema ${extSchema} (pg_stat_statements location)`);
852
+ }
853
+ }
854
+
841
855
  // Some providers don't allow setting search_path via ALTER USER - skip this check.
842
856
  // TODO: Make this more flexible by allowing users to specify which checks to skip via config.
843
857
  if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
@@ -848,10 +862,17 @@ export async function verifyInitSetup(params: {
848
862
  missingRequired.push("role search_path is set");
849
863
  } else {
850
864
  // We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
865
+ // Also verify search_path includes the pg_stat_statements schema if in a non-standard location.
851
866
  const sp = spLine.toLowerCase();
852
867
  if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
853
868
  missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
854
869
  }
870
+ // If pg_stat_statements is in a non-standard schema (e.g., 'extensions' on Supabase), verify it's in search_path
871
+ if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
872
+ if (!sp.includes(extSchema.toLowerCase())) {
873
+ missingRequired.push(`role search_path includes ${extSchema} (pg_stat_statements location)`);
874
+ }
875
+ }
855
876
  }
856
877
  }
857
878
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-dev.84",
3
+ "version": "0.14.0-dev.86",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
32
32
  -- Hardened clusters sometimes revoke PUBLIC on schema public
33
33
  grant usage on schema public to {{ROLE_IDENT}};
34
34
 
35
- -- Keep search_path predictable; postgres_ai first so our objects are found
36
- alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
35
+ -- Grant access to the schema where pg_stat_statements is installed.
36
+ -- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
37
+ -- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
38
+ do $$
39
+ declare
40
+ ext_schema text;
41
+ begin
42
+ select n.nspname into ext_schema
43
+ from pg_extension e
44
+ join pg_namespace n on e.extnamespace = n.oid
45
+ where e.extname = 'pg_stat_statements';
46
+
47
+ -- Only grant if extension exists and is in a non-standard schema
48
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
49
+ execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
50
+ end if;
51
+ end $$;
52
+
53
+ -- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
54
+ -- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
55
+ do $$
56
+ declare
57
+ ext_schema text;
58
+ sp text;
59
+ begin
60
+ -- Detect pg_stat_statements extension schema
61
+ select n.nspname into ext_schema
62
+ from pg_extension e
63
+ join pg_namespace n on e.extnamespace = n.oid
64
+ where e.extname = 'pg_stat_statements';
65
+
66
+ -- Build search_path: include extension schema if in non-standard location
67
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
68
+ sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
69
+ else
70
+ sp := 'postgres_ai, "$user", public, pg_catalog';
71
+ end if;
72
+
73
+ execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
74
+ end $$;
75
+ -- [SEARCH_PATH_BLOCK_END]
37
76
 
38
77
 
@@ -1008,6 +1008,47 @@ describe("CLI tests", () => {
1008
1008
  const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload"], env);
1009
1009
  expect(r.stderr).not.toMatch(/API key is required/i);
1010
1010
  });
1011
+
1012
+ // Argument parsing tests for check ID / connection string recognition
1013
+ test("checkup with check ID but no connection shows specific error", () => {
1014
+ const r = runCli(["checkup", "H002"]);
1015
+ expect(r.status).not.toBe(0);
1016
+ expect(r.stderr).toMatch(/connection string required/i);
1017
+ expect(r.stderr).toMatch(/H002/);
1018
+ });
1019
+
1020
+ test("checkup recognizes valid check ID patterns", () => {
1021
+ // Valid check IDs: A002, H002, F004, etc.
1022
+ for (const checkId of ["A002", "H002", "F004", "K003", "a002", "h002"]) {
1023
+ const r = runCli(["checkup", checkId]);
1024
+ expect(r.status).not.toBe(0);
1025
+ expect(r.stderr).toMatch(/connection string required/i);
1026
+ }
1027
+ });
1028
+
1029
+ test("checkup does not treat connection string as check ID", () => {
1030
+ // Connection strings should not be parsed as check IDs
1031
+ const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
1032
+ // Should not show "connection string required" error
1033
+ expect(r.stderr).not.toMatch(/connection string required/i);
1034
+ });
1035
+
1036
+ test("checkup with check ID and connection string works", () => {
1037
+ // pgai checkup H002 postgresql://...
1038
+ const r = runCli(["checkup", "H002", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
1039
+ // Should not show "connection string required" error
1040
+ expect(r.stderr).not.toMatch(/connection string required/i);
1041
+ // Connection will fail but argument parsing should succeed
1042
+ expect(r.stderr).not.toMatch(/unknown option/i);
1043
+ });
1044
+
1045
+ test("checkup with --check-id option works", () => {
1046
+ // pgai checkup --check-id H002 postgresql://...
1047
+ const r = runCli(["checkup", "--check-id", "H002", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
1048
+ // Should not show "connection string required" error
1049
+ expect(r.stderr).not.toMatch(/connection string required/i);
1050
+ expect(r.stderr).not.toMatch(/unknown option/i);
1051
+ });
1011
1052
  });
1012
1053
 
1013
1054
  // Tests for checkup-api module
package/test/init.test.ts CHANGED
@@ -281,7 +281,7 @@ describe("init module", () => {
281
281
  return { rowCount: 1, rows: [] };
282
282
  }
283
283
  if (String(sql).includes("select rolconfig")) {
284
- return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
284
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
285
285
  }
286
286
  if (String(sql).includes("from pg_catalog.pg_roles")) {
287
287
  return { rowCount: 1, rows: [] };
@@ -307,6 +307,10 @@ describe("init module", () => {
307
307
  if (String(sql).includes("has_schema_privilege")) {
308
308
  return { rowCount: 1, rows: [{ ok: true }] };
309
309
  }
310
+ // Query for pg_stat_statements extension schema location
311
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
312
+ return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
313
+ }
310
314
 
311
315
  throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
312
316
  },
@@ -363,6 +367,10 @@ describe("init module", () => {
363
367
  if (String(sql).includes("has_schema_privilege")) {
364
368
  return { rowCount: 1, rows: [{ ok: true }] };
365
369
  }
370
+ // Query for pg_stat_statements extension schema location (Supabase uses 'extensions' schema)
371
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
372
+ return { rowCount: 1, rows: [{ schema: "extensions" }] };
373
+ }
366
374
 
367
375
  throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
368
376
  },
@@ -382,6 +390,335 @@ describe("init module", () => {
382
390
  expect(calls.some((c) => c.includes("select rolconfig"))).toBe(false);
383
391
  });
384
392
 
393
+ test("verifyInitSetup checks extensions schema when pg_stat_statements is there", async () => {
394
+ const calls: string[] = [];
395
+ const client = {
396
+ query: async (sql: string, params?: any) => {
397
+ calls.push(String(sql));
398
+
399
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
400
+ return { rowCount: 1, rows: [] };
401
+ }
402
+ if (String(sql).toLowerCase() === "rollback;") {
403
+ return { rowCount: 1, rows: [] };
404
+ }
405
+ if (String(sql).includes("select rolconfig")) {
406
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
407
+ }
408
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
409
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
410
+ }
411
+ if (String(sql).includes("has_database_privilege")) {
412
+ return { rowCount: 1, rows: [{ ok: true }] };
413
+ }
414
+ if (String(sql).includes("pg_has_role")) {
415
+ return { rowCount: 1, rows: [{ ok: true }] };
416
+ }
417
+ if (String(sql).includes("has_table_privilege")) {
418
+ return { rowCount: 1, rows: [{ ok: true }] };
419
+ }
420
+ if (String(sql).includes("to_regclass")) {
421
+ return { rowCount: 1, rows: [{ ok: true }] };
422
+ }
423
+ if (String(sql).includes("has_function_privilege")) {
424
+ return { rowCount: 1, rows: [{ ok: true }] };
425
+ }
426
+ // pg_stat_statements is in 'extensions' schema
427
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
428
+ return { rowCount: 1, rows: [{ schema: "extensions" }] };
429
+ }
430
+ // Check for USAGE on extensions schema
431
+ if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
432
+ return { rowCount: 1, rows: [{ ok: true }] };
433
+ }
434
+ if (String(sql).includes("has_schema_privilege")) {
435
+ return { rowCount: 1, rows: [{ ok: true }] };
436
+ }
437
+
438
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
439
+ },
440
+ };
441
+
442
+ const r = await init.verifyInitSetup({
443
+ client: client as any,
444
+ database: "mydb",
445
+ monitoringUser: DEFAULT_MONITORING_USER,
446
+ includeOptionalPermissions: false,
447
+ });
448
+ expect(r.ok).toBe(true);
449
+ expect(r.missingRequired.length).toBe(0);
450
+ // Should have queried for pg_stat_statements schema location
451
+ expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
452
+ });
453
+
454
+ test("verifyInitSetup reports missing extensions schema access", async () => {
455
+ const client = {
456
+ query: async (sql: string, params?: any) => {
457
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
458
+ return { rowCount: 1, rows: [] };
459
+ }
460
+ if (String(sql).toLowerCase() === "rollback;") {
461
+ return { rowCount: 1, rows: [] };
462
+ }
463
+ if (String(sql).includes("select rolconfig")) {
464
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
465
+ }
466
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
467
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
468
+ }
469
+ if (String(sql).includes("has_database_privilege")) {
470
+ return { rowCount: 1, rows: [{ ok: true }] };
471
+ }
472
+ if (String(sql).includes("pg_has_role")) {
473
+ return { rowCount: 1, rows: [{ ok: true }] };
474
+ }
475
+ if (String(sql).includes("has_table_privilege")) {
476
+ return { rowCount: 1, rows: [{ ok: true }] };
477
+ }
478
+ if (String(sql).includes("to_regclass")) {
479
+ return { rowCount: 1, rows: [{ ok: true }] };
480
+ }
481
+ if (String(sql).includes("has_function_privilege")) {
482
+ return { rowCount: 1, rows: [{ ok: true }] };
483
+ }
484
+ // pg_stat_statements is in 'extensions' schema
485
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
486
+ return { rowCount: 1, rows: [{ schema: "extensions" }] };
487
+ }
488
+ // No USAGE on extensions schema
489
+ if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
490
+ return { rowCount: 1, rows: [{ ok: false }] };
491
+ }
492
+ if (String(sql).includes("has_schema_privilege")) {
493
+ return { rowCount: 1, rows: [{ ok: true }] };
494
+ }
495
+
496
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
497
+ },
498
+ };
499
+
500
+ const r = await init.verifyInitSetup({
501
+ client: client as any,
502
+ database: "mydb",
503
+ monitoringUser: DEFAULT_MONITORING_USER,
504
+ includeOptionalPermissions: false,
505
+ });
506
+ expect(r.ok).toBe(false);
507
+ // Should report missing USAGE on extensions schema
508
+ expect(r.missingRequired.some((m) => m.includes("extensions") && m.includes("pg_stat_statements"))).toBe(true);
509
+ // Should also report missing extensions in search_path
510
+ expect(r.missingRequired.some((m) => m.includes("search_path") && m.includes("extensions"))).toBe(true);
511
+ });
512
+
513
+ test("buildInitPlan includes dynamic search_path with extension schema detection", async () => {
514
+ const plan = await init.buildInitPlan({
515
+ database: "mydb",
516
+ monitoringUser: DEFAULT_MONITORING_USER,
517
+ monitoringPassword: "pw",
518
+ includeOptionalPermissions: false,
519
+ });
520
+
521
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
522
+ expect(permStep).toBeTruthy();
523
+ // Should use dynamic DO block to set search_path based on detected extension schema
524
+ expect(permStep!.sql).toMatch(/alter\s+user.*set\s+search_path\s*=/i);
525
+ // Should detect pg_stat_statements extension schema dynamically
526
+ expect(permStep!.sql).toMatch(/quote_ident\(ext_schema\)/i);
527
+ });
528
+
529
+ test("buildInitPlan includes dynamic extension schema grant", async () => {
530
+ const plan = await init.buildInitPlan({
531
+ database: "mydb",
532
+ monitoringUser: DEFAULT_MONITORING_USER,
533
+ monitoringPassword: "pw",
534
+ includeOptionalPermissions: false,
535
+ });
536
+
537
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
538
+ expect(permStep).toBeTruthy();
539
+ // Should include DO block that grants USAGE on extension schema
540
+ expect(permStep!.sql).toMatch(/do\s+\$\$/i);
541
+ expect(permStep!.sql).toMatch(/pg_stat_statements/);
542
+ expect(permStep!.sql).toMatch(/grant usage on schema/i);
543
+ });
544
+
545
+ test("verifyInitSetup handles pg_stat_statements not installed", async () => {
546
+ const calls: string[] = [];
547
+ const client = {
548
+ query: async (sql: string, params?: any) => {
549
+ calls.push(String(sql));
550
+
551
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
552
+ return { rowCount: 1, rows: [] };
553
+ }
554
+ if (String(sql).toLowerCase() === "rollback;") {
555
+ return { rowCount: 1, rows: [] };
556
+ }
557
+ if (String(sql).includes("select rolconfig")) {
558
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
559
+ }
560
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
561
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
562
+ }
563
+ if (String(sql).includes("has_database_privilege")) {
564
+ return { rowCount: 1, rows: [{ ok: true }] };
565
+ }
566
+ if (String(sql).includes("pg_has_role")) {
567
+ return { rowCount: 1, rows: [{ ok: true }] };
568
+ }
569
+ if (String(sql).includes("has_table_privilege")) {
570
+ return { rowCount: 1, rows: [{ ok: true }] };
571
+ }
572
+ if (String(sql).includes("to_regclass")) {
573
+ return { rowCount: 1, rows: [{ ok: true }] };
574
+ }
575
+ if (String(sql).includes("has_function_privilege")) {
576
+ return { rowCount: 1, rows: [{ ok: true }] };
577
+ }
578
+ // pg_stat_statements is NOT installed - empty result
579
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
580
+ return { rowCount: 0, rows: [] };
581
+ }
582
+ if (String(sql).includes("has_schema_privilege")) {
583
+ return { rowCount: 1, rows: [{ ok: true }] };
584
+ }
585
+
586
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
587
+ },
588
+ };
589
+
590
+ const r = await init.verifyInitSetup({
591
+ client: client as any,
592
+ database: "mydb",
593
+ monitoringUser: DEFAULT_MONITORING_USER,
594
+ includeOptionalPermissions: false,
595
+ });
596
+ // Should pass without errors - missing extension shouldn't cause failure
597
+ expect(r.ok).toBe(true);
598
+ expect(r.missingRequired.length).toBe(0);
599
+ // Should have queried for pg_stat_statements schema location
600
+ expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
601
+ });
602
+
603
+ test("verifyInitSetup skips extension schema check when in pg_catalog", async () => {
604
+ const calls: string[] = [];
605
+ const client = {
606
+ query: async (sql: string, params?: any) => {
607
+ calls.push(String(sql));
608
+
609
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
610
+ return { rowCount: 1, rows: [] };
611
+ }
612
+ if (String(sql).toLowerCase() === "rollback;") {
613
+ return { rowCount: 1, rows: [] };
614
+ }
615
+ if (String(sql).includes("select rolconfig")) {
616
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
617
+ }
618
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
619
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
620
+ }
621
+ if (String(sql).includes("has_database_privilege")) {
622
+ return { rowCount: 1, rows: [{ ok: true }] };
623
+ }
624
+ if (String(sql).includes("pg_has_role")) {
625
+ return { rowCount: 1, rows: [{ ok: true }] };
626
+ }
627
+ if (String(sql).includes("has_table_privilege")) {
628
+ return { rowCount: 1, rows: [{ ok: true }] };
629
+ }
630
+ if (String(sql).includes("to_regclass")) {
631
+ return { rowCount: 1, rows: [{ ok: true }] };
632
+ }
633
+ if (String(sql).includes("has_function_privilege")) {
634
+ return { rowCount: 1, rows: [{ ok: true }] };
635
+ }
636
+ // pg_stat_statements is in pg_catalog (standard location)
637
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
638
+ return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
639
+ }
640
+ if (String(sql).includes("has_schema_privilege")) {
641
+ return { rowCount: 1, rows: [{ ok: true }] };
642
+ }
643
+
644
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
645
+ },
646
+ };
647
+
648
+ const r = await init.verifyInitSetup({
649
+ client: client as any,
650
+ database: "mydb",
651
+ monitoringUser: DEFAULT_MONITORING_USER,
652
+ includeOptionalPermissions: false,
653
+ });
654
+ // Should pass - pg_catalog doesn't need extra USAGE grant
655
+ expect(r.ok).toBe(true);
656
+ expect(r.missingRequired.length).toBe(0);
657
+ // Should NOT have queried for has_schema_privilege on pg_catalog specifically
658
+ // (the code skips the check for pg_catalog and public schemas)
659
+ const pgCatalogPrivCheck = calls.filter(
660
+ (c) => c.includes("has_schema_privilege") && c.includes("pg_catalog")
661
+ );
662
+ // Should only have the standard public schema check, not a pg_catalog check for extension
663
+ expect(pgCatalogPrivCheck.length).toBe(0);
664
+ });
665
+
666
+ test("verifyInitSetup skips extension schema check when in public", async () => {
667
+ const calls: string[] = [];
668
+ const client = {
669
+ query: async (sql: string, params?: any) => {
670
+ calls.push(String(sql));
671
+
672
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
673
+ return { rowCount: 1, rows: [] };
674
+ }
675
+ if (String(sql).toLowerCase() === "rollback;") {
676
+ return { rowCount: 1, rows: [] };
677
+ }
678
+ if (String(sql).includes("select rolconfig")) {
679
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
680
+ }
681
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
682
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
683
+ }
684
+ if (String(sql).includes("has_database_privilege")) {
685
+ return { rowCount: 1, rows: [{ ok: true }] };
686
+ }
687
+ if (String(sql).includes("pg_has_role")) {
688
+ return { rowCount: 1, rows: [{ ok: true }] };
689
+ }
690
+ if (String(sql).includes("has_table_privilege")) {
691
+ return { rowCount: 1, rows: [{ ok: true }] };
692
+ }
693
+ if (String(sql).includes("to_regclass")) {
694
+ return { rowCount: 1, rows: [{ ok: true }] };
695
+ }
696
+ if (String(sql).includes("has_function_privilege")) {
697
+ return { rowCount: 1, rows: [{ ok: true }] };
698
+ }
699
+ // pg_stat_statements is in public schema
700
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
701
+ return { rowCount: 1, rows: [{ schema: "public" }] };
702
+ }
703
+ if (String(sql).includes("has_schema_privilege")) {
704
+ return { rowCount: 1, rows: [{ ok: true }] };
705
+ }
706
+
707
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
708
+ },
709
+ };
710
+
711
+ const r = await init.verifyInitSetup({
712
+ client: client as any,
713
+ database: "mydb",
714
+ monitoringUser: DEFAULT_MONITORING_USER,
715
+ includeOptionalPermissions: false,
716
+ });
717
+ // Should pass - public doesn't need extra USAGE grant for extension
718
+ expect(r.ok).toBe(true);
719
+ expect(r.missingRequired.length).toBe(0);
720
+ });
721
+
385
722
  test("buildInitPlan preserves comments when filtering ALTER USER", async () => {
386
723
  const plan = await init.buildInitPlan({
387
724
  database: "mydb",