postgresai 0.16.0-rc.3 → 0.16.0-rc.4

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ### Fixed
6
+
7
+ - `checkup --markdown` previously performed server-side conversion and sent the
8
+ full report JSON to the PostgresAI API even when `--no-upload` was set. The
9
+ flags are now mutually exclusive, and `--no-upload` prevents report data from
10
+ being sent to the PostgresAI API. Use `--json` or `--output` for local-only
11
+ output.
@@ -335,12 +335,18 @@ function prepareUploadConfig(
335
335
  console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
336
336
  return null; // Signal to exit
337
337
  }
338
- // No credentials and upload not explicitly requested: fall back to
339
- // local-only mode, but say so prominently skipping the upload silently
340
- // hides the fact that results never reach the Console.
341
- console.error("Notice: no API key configured results will NOT be uploaded to PostgresAI.");
342
- console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
343
- console.error(" To run locally without this notice, pass --no-upload.");
338
+ if (opts.markdown) {
339
+ console.error("Notice: no API key configuredregular report upload is disabled.");
340
+ console.error(" The full report JSON will still be sent to the PostgresAI API for markdown conversion.");
341
+ console.error(" To avoid sending report data, replace --markdown with --no-upload and --json or --output.");
342
+ } else {
343
+ // No credentials and upload not explicitly requested: fall back to
344
+ // local-only mode, but say so prominently — skipping the upload silently
345
+ // hides the fact that results never reach the Console.
346
+ console.error("Notice: no API key configured — results will NOT be uploaded to PostgresAI.");
347
+ console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
348
+ console.error(" To run locally without this notice, pass --no-upload.");
349
+ }
344
350
  return undefined; // Skip upload, run checks locally
345
351
  }
346
352
 
@@ -1996,7 +2002,7 @@ program
1996
2002
  "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
1997
2003
  )
1998
2004
  .option("--json", "output JSON to stdout")
1999
- .option("--markdown", "output markdown to stdout")
2005
+ .option("--markdown", "output markdown via PostgresAI API (transmits the full report JSON)")
2000
2006
  .addHelpText(
2001
2007
  "after",
2002
2008
  [
@@ -2010,7 +2016,7 @@ program
2010
2016
  " postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
2011
2017
  " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
2012
2018
  " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
2013
- " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --markdown",
2019
+ " postgresai checkup postgresql://user:pass@host:5432/db --markdown",
2014
2020
  ].join("\n")
2015
2021
  )
2016
2022
  .action(async (checkIdOrConn: string | undefined, connArg: string | undefined, opts: CheckupOptions, cmd: Command) => {
@@ -2060,9 +2066,14 @@ program
2060
2066
  process.exitCode = 1;
2061
2067
  return;
2062
2068
  }
2063
- // Note: --json, --markdown and --upload/--no-upload are independent flags.
2064
- // Use --no-upload to explicitly disable upload when using --json or --markdown.
2065
2069
  const uploadExplicitlyDisabled = opts.upload === false;
2070
+ if (uploadExplicitlyDisabled && shouldConvertMarkdown) {
2071
+ console.error("Error: --no-upload and --markdown are mutually exclusive");
2072
+ console.error("Markdown conversion is performed by the PostgresAI API and transmits the full report JSON.");
2073
+ console.error("Drop --no-upload to allow transmission, or use --json or --output for local-only output.");
2074
+ process.exitCode = 1;
2075
+ return;
2076
+ }
2066
2077
  let shouldUpload = !uploadExplicitlyDisabled;
2067
2078
 
2068
2079
  // Preflight: validate/create output directory BEFORE connecting / running checks.
@@ -2308,7 +2319,9 @@ program
2308
2319
 
2309
2320
  console.log('\nFor details:');
2310
2321
  console.log(' --json Output JSON');
2311
- console.log(' --markdown Output markdown');
2322
+ if (!uploadExplicitlyDisabled) {
2323
+ console.log(' --markdown Output markdown via PostgresAI API');
2324
+ }
2312
2325
  console.log(' --output <dir> Save to directory');
2313
2326
  }
2314
2327
  } catch (error) {
@@ -3931,13 +3944,9 @@ targets
3931
3944
  // Authentication and API key management
3932
3945
  const auth = program.command("auth").description("authentication and API key management");
3933
3946
 
3934
- auth
3935
- .command("login", { isDefault: true })
3936
- .description("authenticate via browser (OAuth) or store API key directly")
3937
- .option("--set-key <key>", "store API key directly without OAuth flow")
3938
- .option("--port <port>", "local callback server port (default: random)", parseInt)
3939
- .option("--debug", "enable debug output")
3940
- .action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
3947
+ type AuthLoginOptions = { setKey?: string; port?: number; debug?: boolean };
3948
+
3949
+ async function runAuthLogin(opts: AuthLoginOptions) {
3941
3950
  // If --set-key is provided, store it directly without OAuth
3942
3951
  if (opts.setKey) {
3943
3952
  const trimmedKey = opts.setKey.trim();
@@ -4191,7 +4200,21 @@ auth
4191
4200
  console.error(`Authentication error: ${message}`);
4192
4201
  process.exit(1);
4193
4202
  }
4194
- });
4203
+ }
4204
+
4205
+ function configureLoginCommand(command: Command): Command {
4206
+ return command
4207
+ .description("authenticate via browser (OAuth) or store API key directly")
4208
+ .option("--set-key <key>", "store API key directly without OAuth flow")
4209
+ .option("--port <port>", "local callback server port (default: random)", parseInt)
4210
+ .option("--debug", "enable debug output");
4211
+ }
4212
+
4213
+ configureLoginCommand(auth.command("login", { isDefault: true }))
4214
+ .action(runAuthLogin);
4215
+
4216
+ configureLoginCommand(program.command("login"))
4217
+ .action(runAuthLogin);
4195
4218
 
4196
4219
  auth
4197
4220
  .command("show-key")
@@ -13425,7 +13425,7 @@ var {
13425
13425
  // package.json
13426
13426
  var package_default = {
13427
13427
  name: "postgresai",
13428
- version: "0.16.0-rc.3",
13428
+ version: "0.16.0-rc.4",
13429
13429
  description: "postgres_ai CLI",
13430
13430
  license: "Apache-2.0",
13431
13431
  private: false,
@@ -16256,7 +16256,7 @@ var Result = import_lib.default.Result;
16256
16256
  var TypeOverrides = import_lib.default.TypeOverrides;
16257
16257
  var defaults = import_lib.default.defaults;
16258
16258
  // package.json
16259
- var version = "0.16.0-rc.3";
16259
+ var version = "0.16.0-rc.4";
16260
16260
  var package_default2 = {
16261
16261
  name: "postgresai",
16262
16262
  version,
@@ -29518,10 +29518,28 @@ async function checkCurrentUserPermissions(client) {
29518
29518
 
29519
29519
  union all
29520
29520
 
29521
+ select
29522
+ 'postgres_ai schema exists' as permission_name,
29523
+ 'optional' as status,
29524
+ to_regnamespace('postgres_ai') is not null as granted
29525
+
29526
+ union all
29527
+
29528
+ select
29529
+ 'usage on postgres_ai schema' as permission_name,
29530
+ 'optional' as status,
29531
+ case
29532
+ when to_regnamespace('postgres_ai') is null then null
29533
+ else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
29534
+ end as granted
29535
+
29536
+ union all
29537
+
29521
29538
  select
29522
29539
  'postgres_ai.pg_statistic view exists' as permission_name,
29523
29540
  'optional' as status,
29524
29541
  case
29542
+ when to_regnamespace('postgres_ai') is null then null
29525
29543
  when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
29526
29544
  else to_regclass('postgres_ai.pg_statistic') is not null
29527
29545
  end as granted
@@ -29532,6 +29550,7 @@ async function checkCurrentUserPermissions(client) {
29532
29550
  'select on postgres_ai.pg_statistic' as permission_name,
29533
29551
  'optional' as status,
29534
29552
  case
29553
+ when to_regnamespace('postgres_ai') is null then null
29535
29554
  when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
29536
29555
  when to_regclass('postgres_ai.pg_statistic') is null then null
29537
29556
  else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
@@ -29551,6 +29570,10 @@ async function checkCurrentUserPermissions(client) {
29551
29570
  when permission_name like 'select on pg_catalog.pg_index' then
29552
29571
  format('grant select on pg_catalog.pg_index to %I;', current_user)
29553
29572
  end
29573
+ when permission_name = 'postgres_ai schema exists' and granted = false then
29574
+ '-- run postgresai prepare-db or create the postgres_ai schema and pg_statistic view manually'
29575
+ when permission_name = 'usage on postgres_ai schema' and granted = false then
29576
+ format('grant usage on schema postgres_ai to %I;', current_user)
29554
29577
  when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
29555
29578
  '-- create postgres_ai.pg_statistic view (see setup script)'
29556
29579
  when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
@@ -29577,6 +29600,10 @@ function formatPermissionCheckMessages(result) {
29577
29600
  const warnings = [];
29578
29601
  const errors3 = [];
29579
29602
  for (const row of result.missingOptional) {
29603
+ if (row.permission_name === "postgres_ai schema exists") {
29604
+ warnings.push("Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them.");
29605
+ continue;
29606
+ }
29580
29607
  const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
29581
29608
  warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
29582
29609
  }
@@ -32765,12 +32792,70 @@ async function generateF003(client, nodeName) {
32765
32792
  };
32766
32793
  return report;
32767
32794
  }
32795
+ function bloatErrorStatus(err) {
32796
+ const error2 = err instanceof Error ? err.message : String(err);
32797
+ const code = typeof err === "object" && err !== null && "code" in err ? String(err.code || "") : "";
32798
+ const normalized = error2.toLowerCase();
32799
+ let reason = "query_error";
32800
+ if (code === "3F000" || normalized.includes('schema "postgres_ai" does not exist')) {
32801
+ reason = "missing_schema";
32802
+ } else if (code === "42P01" || normalized.includes('relation "postgres_ai.pg_statistic" does not exist')) {
32803
+ reason = "missing_view";
32804
+ } else if (code === "42501" || normalized.includes("permission denied")) {
32805
+ reason = "missing_grant";
32806
+ }
32807
+ return { ok: false, reason, error: error2 };
32808
+ }
32809
+ async function getBloatCheckStatus(client) {
32810
+ try {
32811
+ const result = await client.query(`
32812
+ select
32813
+ to_regnamespace('postgres_ai') is not null as schema_exists,
32814
+ case
32815
+ when to_regnamespace('postgres_ai') is null then false
32816
+ else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
32817
+ end as schema_usage,
32818
+ case
32819
+ when to_regnamespace('postgres_ai') is null then false
32820
+ when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
32821
+ else to_regclass('postgres_ai.pg_statistic') is not null
32822
+ end as view_exists,
32823
+ case
32824
+ when to_regnamespace('postgres_ai') is null then false
32825
+ when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
32826
+ when to_regclass('postgres_ai.pg_statistic') is null then false
32827
+ else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'SELECT')
32828
+ end as view_select
32829
+ `);
32830
+ const capability = result.rows[0] || {};
32831
+ if (!capability.schema_exists) {
32832
+ return { ok: false, reason: "missing_schema", error: 'schema "postgres_ai" does not exist' };
32833
+ }
32834
+ if (!capability.schema_usage) {
32835
+ return { ok: false, reason: "missing_grant", error: "permission denied for schema postgres_ai" };
32836
+ }
32837
+ if (!capability.view_exists) {
32838
+ return { ok: false, reason: "missing_view", error: 'relation "postgres_ai.pg_statistic" does not exist' };
32839
+ }
32840
+ if (!capability.view_select) {
32841
+ return { ok: false, reason: "missing_grant", error: "permission denied for relation postgres_ai.pg_statistic" };
32842
+ }
32843
+ return { ok: true, reason: null, error: null };
32844
+ } catch (err) {
32845
+ return bloatErrorStatus(err);
32846
+ }
32847
+ }
32768
32848
  async function generateF004(client, nodeName) {
32769
32849
  const report = createBaseReport("F004", "Autovacuum: heap bloat (estimated)", nodeName);
32770
32850
  const postgresVersion = await getPostgresVersion(client);
32771
32851
  const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
32772
32852
  let bloatedTables = [];
32853
+ let status = await getBloatCheckStatus(client);
32773
32854
  try {
32855
+ if (!status.ok)
32856
+ throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
32857
+ code: status.reason === "missing_schema" ? "3F000" : status.reason === "missing_view" ? "42P01" : status.reason === "missing_grant" ? "42501" : undefined
32858
+ });
32774
32859
  const sql = getMetricSql(METRIC_NAMES.F004, pgMajorVersion);
32775
32860
  const bloatResult = await client.query(sql);
32776
32861
  const vacuumStatsResult = await client.query(`
@@ -32814,7 +32899,8 @@ async function generateF004(client, nodeName) {
32814
32899
  };
32815
32900
  });
32816
32901
  } catch (err) {
32817
- const errorMsg = err instanceof Error ? err.message : String(err);
32902
+ status = bloatErrorStatus(err);
32903
+ const errorMsg = status.error || "Unknown error";
32818
32904
  console.error(`[F004] Error estimating table bloat: ${errorMsg}`);
32819
32905
  if (errorMsg.includes("postgres_ai.")) {
32820
32906
  console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
@@ -32824,6 +32910,7 @@ async function generateF004(client, nodeName) {
32824
32910
  const totalCount = bloatedTables.length;
32825
32911
  const totalBloatSizeBytes = bloatedTables.reduce((sum, t) => sum + t.bloat_size, 0);
32826
32912
  const dbEntry = {
32913
+ status,
32827
32914
  bloated_tables: bloatedTables,
32828
32915
  total_count: totalCount,
32829
32916
  total_bloat_size_bytes: totalBloatSizeBytes,
@@ -32842,7 +32929,12 @@ async function generateF005(client, nodeName) {
32842
32929
  const postgresVersion = await getPostgresVersion(client);
32843
32930
  const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
32844
32931
  let bloatedIndexes = [];
32932
+ let status = await getBloatCheckStatus(client);
32845
32933
  try {
32934
+ if (!status.ok)
32935
+ throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
32936
+ code: status.reason === "missing_schema" ? "3F000" : status.reason === "missing_view" ? "42P01" : status.reason === "missing_grant" ? "42501" : undefined
32937
+ });
32846
32938
  const sql = getMetricSql(METRIC_NAMES.F005, pgMajorVersion);
32847
32939
  const bloatResult = await client.query(sql);
32848
32940
  const vacuumStatsResult = await client.query(`
@@ -32891,7 +32983,8 @@ async function generateF005(client, nodeName) {
32891
32983
  };
32892
32984
  });
32893
32985
  } catch (err) {
32894
- const errorMsg = err instanceof Error ? err.message : String(err);
32986
+ status = bloatErrorStatus(err);
32987
+ const errorMsg = status.error || "Unknown error";
32895
32988
  console.error(`[F005] Error estimating index bloat: ${errorMsg}`);
32896
32989
  if (errorMsg.includes("postgres_ai.")) {
32897
32990
  console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
@@ -32901,6 +32994,7 @@ async function generateF005(client, nodeName) {
32901
32994
  const totalCount = bloatedIndexes.length;
32902
32995
  const totalBloatSizeBytes = bloatedIndexes.reduce((sum, idx) => sum + idx.bloat_size, 0);
32903
32996
  const dbEntry = {
32997
+ status,
32904
32998
  bloated_indexes: bloatedIndexes,
32905
32999
  total_count: totalCount,
32906
33000
  total_bloat_size_bytes: totalBloatSizeBytes,
@@ -33573,6 +33667,10 @@ function generateCheckSummary(checkId, report) {
33573
33667
  return summarizeF001(nodeData);
33574
33668
  case "F003":
33575
33669
  return summarizeF003(nodeData);
33670
+ case "F004":
33671
+ return summarizeBloat(nodeData, "table");
33672
+ case "F005":
33673
+ return summarizeBloat(nodeData, "index");
33576
33674
  case "G001":
33577
33675
  return summarizeG001(nodeData);
33578
33676
  case "G003":
@@ -33758,6 +33856,25 @@ function summarizeF003(nodeData) {
33758
33856
  }
33759
33857
  return { status: "warning", message: parts.join(", ") };
33760
33858
  }
33859
+ function summarizeBloat(nodeData, kind) {
33860
+ const data = nodeData?.data || {};
33861
+ let totalCount = 0;
33862
+ for (const dbData of Object.values(data)) {
33863
+ const dbEntry = dbData;
33864
+ if (dbEntry?.status?.ok === false) {
33865
+ const reason = String(dbEntry.status.reason || "query_error").replaceAll("_", " ");
33866
+ return { status: "warning", message: `Bloat estimate degraded: ${reason}` };
33867
+ }
33868
+ totalCount += dbEntry?.total_count || 0;
33869
+ }
33870
+ if (totalCount === 0) {
33871
+ return { status: "ok", message: `No bloated ${kind}${kind === "index" ? "es" : "s"} found` };
33872
+ }
33873
+ return {
33874
+ status: "warning",
33875
+ message: `Found ${totalCount} bloated ${kind}${totalCount === 1 ? "" : kind === "index" ? "es" : "s"}`
33876
+ };
33877
+ }
33761
33878
  function summarizeG001(nodeData) {
33762
33879
  const data = nodeData?.data || {};
33763
33880
  const settingsCount = Object.keys(data).length;
@@ -34114,9 +34231,15 @@ function prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyReque
34114
34231
  console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
34115
34232
  return null;
34116
34233
  }
34117
- console.error("Notice: no API key configured \u2014 results will NOT be uploaded to PostgresAI.");
34118
- console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
34119
- console.error(" To run locally without this notice, pass --no-upload.");
34234
+ if (opts.markdown) {
34235
+ console.error("Notice: no API key configured \u2014 regular report upload is disabled.");
34236
+ console.error(" The full report JSON will still be sent to the PostgresAI API for markdown conversion.");
34237
+ console.error(" To avoid sending report data, replace --markdown with --no-upload and --json or --output.");
34238
+ } else {
34239
+ console.error("Notice: no API key configured \u2014 results will NOT be uploaded to PostgresAI.");
34240
+ console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
34241
+ console.error(" To run locally without this notice, pass --no-upload.");
34242
+ }
34120
34243
  return;
34121
34244
  }
34122
34245
  const cfg = readConfig();
@@ -35341,7 +35464,7 @@ program2.command("unprepare-db [conn]").description("remove monitoring setup: dr
35341
35464
  closeReadline();
35342
35465
  }
35343
35466
  });
35344
- 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").option("--markdown", "output markdown to stdout").addHelpText("after", [
35467
+ 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").option("--markdown", "output markdown via PostgresAI API (transmits the full report JSON)").addHelpText("after", [
35345
35468
  "",
35346
35469
  "Available checks:",
35347
35470
  ...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
@@ -35352,7 +35475,7 @@ program2.command("checkup [checkIdOrConn] [conn]").description("generate health
35352
35475
  " postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
35353
35476
  " postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
35354
35477
  " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
35355
- " postgresai checkup postgresql://user:pass@host:5432/db --no-upload --markdown"
35478
+ " postgresai checkup postgresql://user:pass@host:5432/db --markdown"
35356
35479
  ].join(`
35357
35480
  `)).action(async (checkIdOrConn, connArg, opts, cmd) => {
35358
35481
  const checkIdPattern = /^[A-Z]\d{3}$/i;
@@ -35392,6 +35515,13 @@ Usage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname
35392
35515
  return;
35393
35516
  }
35394
35517
  const uploadExplicitlyDisabled = opts.upload === false;
35518
+ if (uploadExplicitlyDisabled && shouldConvertMarkdown) {
35519
+ console.error("Error: --no-upload and --markdown are mutually exclusive");
35520
+ console.error("Markdown conversion is performed by the PostgresAI API and transmits the full report JSON.");
35521
+ console.error("Drop --no-upload to allow transmission, or use --json or --output for local-only output.");
35522
+ process.exitCode = 1;
35523
+ return;
35524
+ }
35395
35525
  let shouldUpload = !uploadExplicitlyDisabled;
35396
35526
  const outputPath = prepareOutputDirectory(opts.output);
35397
35527
  if (outputPath === null) {
@@ -35574,7 +35704,9 @@ Usage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname
35574
35704
  console.log(`
35575
35705
  For details:`);
35576
35706
  console.log(" --json Output JSON");
35577
- console.log(" --markdown Output markdown");
35707
+ if (!uploadExplicitlyDisabled) {
35708
+ console.log(" --markdown Output markdown via PostgresAI API");
35709
+ }
35578
35710
  console.log(" --output <dir> Save to directory");
35579
35711
  }
35580
35712
  } catch (error2) {
@@ -36789,7 +36921,7 @@ targets.command("test <name>").description("test monitoring target database conn
36789
36921
  }
36790
36922
  });
36791
36923
  var auth = program2.command("auth").description("authentication and API key management");
36792
- auth.command("login", { isDefault: true }).description("authenticate via browser (OAuth) or store API key directly").option("--set-key <key>", "store API key directly without OAuth flow").option("--port <port>", "local callback server port (default: random)", parseInt).option("--debug", "enable debug output").action(async (opts) => {
36924
+ async function runAuthLogin(opts) {
36793
36925
  if (opts.setKey) {
36794
36926
  const trimmedKey = opts.setKey.trim();
36795
36927
  if (!trimmedKey) {
@@ -36990,7 +37122,12 @@ Authentication failed: ${message}`);
36990
37122
  console.error(`Authentication error: ${message}`);
36991
37123
  process.exit(1);
36992
37124
  }
36993
- });
37125
+ }
37126
+ function configureLoginCommand(command) {
37127
+ return command.description("authenticate via browser (OAuth) or store API key directly").option("--set-key <key>", "store API key directly without OAuth flow").option("--port <port>", "local callback server port (default: random)", parseInt).option("--debug", "enable debug output");
37128
+ }
37129
+ configureLoginCommand(auth.command("login", { isDefault: true })).action(runAuthLogin);
37130
+ configureLoginCommand(program2.command("login")).action(runAuthLogin);
36994
37131
  auth.command("show-key").description("show API key (masked)").action(async () => {
36995
37132
  const cfg = readConfig();
36996
37133
  if (!cfg.apiKey) {
@@ -41,6 +41,8 @@ export function generateCheckSummary(checkId: string, report: any): CheckSummary
41
41
  case 'D004': return summarizeD004(nodeData);
42
42
  case 'F001': return summarizeF001(nodeData);
43
43
  case 'F003': return summarizeF003(nodeData);
44
+ case 'F004': return summarizeBloat(nodeData, 'table');
45
+ case 'F005': return summarizeBloat(nodeData, 'index');
44
46
  case 'G001': return summarizeG001(nodeData);
45
47
  case 'G003': return summarizeG003(nodeData);
46
48
  default:
@@ -273,6 +275,29 @@ function summarizeF003(nodeData: any): CheckSummary {
273
275
  return { status: 'warning', message: parts.join(', ') };
274
276
  }
275
277
 
278
+ function summarizeBloat(nodeData: any, kind: 'table' | 'index'): CheckSummary {
279
+ const data = nodeData?.data || {};
280
+ let totalCount = 0;
281
+
282
+ for (const dbData of Object.values(data)) {
283
+ const dbEntry = dbData as any;
284
+ if (dbEntry?.status?.ok === false) {
285
+ const reason = String(dbEntry.status.reason || 'query_error').replaceAll('_', ' ');
286
+ return { status: 'warning', message: `Bloat estimate degraded: ${reason}` };
287
+ }
288
+ totalCount += dbEntry?.total_count || 0;
289
+ }
290
+
291
+ if (totalCount === 0) {
292
+ return { status: 'ok', message: `No bloated ${kind}${kind === 'index' ? 'es' : 's'} found` };
293
+ }
294
+
295
+ return {
296
+ status: 'warning',
297
+ message: `Found ${totalCount} bloated ${kind}${totalCount === 1 ? '' : kind === 'index' ? 'es' : 's'}`,
298
+ };
299
+ }
300
+
276
301
  function summarizeG001(nodeData: any): CheckSummary {
277
302
  const data = nodeData?.data || {};
278
303
  const settingsCount = Object.keys(data).length;
package/lib/checkup.ts CHANGED
@@ -1498,6 +1498,74 @@ async function generateF003(client: Client, nodeName: string): Promise<Report> {
1498
1498
  * Uses pg_stats for column statistics to estimate row sizes.
1499
1499
  * SQL loaded from config/pgwatch-prometheus/metrics.yml (pg_table_bloat metric).
1500
1500
  */
1501
+ type BloatCheckReason = "missing_schema" | "missing_view" | "missing_grant" | "query_error";
1502
+
1503
+ interface BloatCheckStatus {
1504
+ ok: boolean;
1505
+ reason: BloatCheckReason | null;
1506
+ error: string | null;
1507
+ }
1508
+
1509
+ function bloatErrorStatus(err: unknown): BloatCheckStatus {
1510
+ const error = err instanceof Error ? err.message : String(err);
1511
+ const code = typeof err === "object" && err !== null && "code" in err
1512
+ ? String((err as { code?: unknown }).code || "")
1513
+ : "";
1514
+ const normalized = error.toLowerCase();
1515
+
1516
+ let reason: BloatCheckReason = "query_error";
1517
+ if (code === "3F000" || normalized.includes('schema "postgres_ai" does not exist')) {
1518
+ reason = "missing_schema";
1519
+ } else if (code === "42P01" || normalized.includes('relation "postgres_ai.pg_statistic" does not exist')) {
1520
+ reason = "missing_view";
1521
+ } else if (code === "42501" || normalized.includes("permission denied")) {
1522
+ reason = "missing_grant";
1523
+ }
1524
+
1525
+ return { ok: false, reason, error };
1526
+ }
1527
+
1528
+ async function getBloatCheckStatus(client: Client): Promise<BloatCheckStatus> {
1529
+ try {
1530
+ const result = await client.query(`
1531
+ select
1532
+ to_regnamespace('postgres_ai') is not null as schema_exists,
1533
+ case
1534
+ when to_regnamespace('postgres_ai') is null then false
1535
+ else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
1536
+ end as schema_usage,
1537
+ case
1538
+ when to_regnamespace('postgres_ai') is null then false
1539
+ when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
1540
+ else to_regclass('postgres_ai.pg_statistic') is not null
1541
+ end as view_exists,
1542
+ case
1543
+ when to_regnamespace('postgres_ai') is null then false
1544
+ when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
1545
+ when to_regclass('postgres_ai.pg_statistic') is null then false
1546
+ else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'SELECT')
1547
+ end as view_select
1548
+ `);
1549
+ const capability = result.rows[0] || {};
1550
+
1551
+ if (!capability.schema_exists) {
1552
+ return { ok: false, reason: "missing_schema", error: 'schema "postgres_ai" does not exist' };
1553
+ }
1554
+ if (!capability.schema_usage) {
1555
+ return { ok: false, reason: "missing_grant", error: "permission denied for schema postgres_ai" };
1556
+ }
1557
+ if (!capability.view_exists) {
1558
+ return { ok: false, reason: "missing_view", error: 'relation "postgres_ai.pg_statistic" does not exist' };
1559
+ }
1560
+ if (!capability.view_select) {
1561
+ return { ok: false, reason: "missing_grant", error: "permission denied for relation postgres_ai.pg_statistic" };
1562
+ }
1563
+ return { ok: true, reason: null, error: null };
1564
+ } catch (err) {
1565
+ return bloatErrorStatus(err);
1566
+ }
1567
+ }
1568
+
1501
1569
  async function generateF004(client: Client, nodeName: string): Promise<Report> {
1502
1570
  const report = createBaseReport("F004", "Autovacuum: heap bloat (estimated)", nodeName);
1503
1571
  const postgresVersion = await getPostgresVersion(client);
@@ -1520,8 +1588,15 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
1520
1588
  }
1521
1589
 
1522
1590
  let bloatedTables: BloatedTable[] = [];
1591
+ let status = await getBloatCheckStatus(client);
1523
1592
 
1524
1593
  try {
1594
+ if (!status.ok) throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
1595
+ code: status.reason === "missing_schema" ? "3F000"
1596
+ : status.reason === "missing_view" ? "42P01"
1597
+ : status.reason === "missing_grant" ? "42501"
1598
+ : undefined,
1599
+ });
1525
1600
  // Get bloat data
1526
1601
  const sql = getMetricSql(METRIC_NAMES.F004, pgMajorVersion);
1527
1602
  const bloatResult = await client.query(sql);
@@ -1572,7 +1647,8 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
1572
1647
  };
1573
1648
  });
1574
1649
  } catch (err) {
1575
- const errorMsg = err instanceof Error ? err.message : String(err);
1650
+ status = bloatErrorStatus(err);
1651
+ const errorMsg = status.error || "Unknown error";
1576
1652
  console.error(`[F004] Error estimating table bloat: ${errorMsg}`);
1577
1653
  if (errorMsg.includes("postgres_ai.")) {
1578
1654
  console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
@@ -1587,6 +1663,7 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
1587
1663
  const totalBloatSizeBytes = bloatedTables.reduce((sum, t) => sum + t.bloat_size, 0);
1588
1664
 
1589
1665
  const dbEntry = {
1666
+ status,
1590
1667
  bloated_tables: bloatedTables,
1591
1668
  total_count: totalCount,
1592
1669
  total_bloat_size_bytes: totalBloatSizeBytes,
@@ -1634,8 +1711,15 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
1634
1711
  }
1635
1712
 
1636
1713
  let bloatedIndexes: BloatedIndex[] = [];
1714
+ let status = await getBloatCheckStatus(client);
1637
1715
 
1638
1716
  try {
1717
+ if (!status.ok) throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
1718
+ code: status.reason === "missing_schema" ? "3F000"
1719
+ : status.reason === "missing_view" ? "42P01"
1720
+ : status.reason === "missing_grant" ? "42501"
1721
+ : undefined,
1722
+ });
1639
1723
  // Get bloat data
1640
1724
  const sql = getMetricSql(METRIC_NAMES.F005, pgMajorVersion);
1641
1725
  const bloatResult = await client.query(sql);
@@ -1690,7 +1774,8 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
1690
1774
  };
1691
1775
  });
1692
1776
  } catch (err) {
1693
- const errorMsg = err instanceof Error ? err.message : String(err);
1777
+ status = bloatErrorStatus(err);
1778
+ const errorMsg = status.error || "Unknown error";
1694
1779
  console.error(`[F005] Error estimating index bloat: ${errorMsg}`);
1695
1780
  if (errorMsg.includes("postgres_ai.")) {
1696
1781
  console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
@@ -1705,6 +1790,7 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
1705
1790
  const totalBloatSizeBytes = bloatedIndexes.reduce((sum, idx) => sum + idx.bloat_size, 0);
1706
1791
 
1707
1792
  const dbEntry = {
1793
+ status,
1708
1794
  bloated_indexes: bloatedIndexes,
1709
1795
  total_count: totalCount,
1710
1796
  total_bloat_size_bytes: totalBloatSizeBytes,
package/lib/init.ts CHANGED
@@ -1019,10 +1019,28 @@ export async function checkCurrentUserPermissions(
1019
1019
 
1020
1020
  union all
1021
1021
 
1022
+ select
1023
+ 'postgres_ai schema exists' as permission_name,
1024
+ 'optional' as status,
1025
+ to_regnamespace('postgres_ai') is not null as granted
1026
+
1027
+ union all
1028
+
1029
+ select
1030
+ 'usage on postgres_ai schema' as permission_name,
1031
+ 'optional' as status,
1032
+ case
1033
+ when to_regnamespace('postgres_ai') is null then null
1034
+ else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
1035
+ end as granted
1036
+
1037
+ union all
1038
+
1022
1039
  select
1023
1040
  'postgres_ai.pg_statistic view exists' as permission_name,
1024
1041
  'optional' as status,
1025
1042
  case
1043
+ when to_regnamespace('postgres_ai') is null then null
1026
1044
  when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
1027
1045
  else to_regclass('postgres_ai.pg_statistic') is not null
1028
1046
  end as granted
@@ -1033,6 +1051,7 @@ export async function checkCurrentUserPermissions(
1033
1051
  'select on postgres_ai.pg_statistic' as permission_name,
1034
1052
  'optional' as status,
1035
1053
  case
1054
+ when to_regnamespace('postgres_ai') is null then null
1036
1055
  when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
1037
1056
  when to_regclass('postgres_ai.pg_statistic') is null then null
1038
1057
  else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
@@ -1052,6 +1071,10 @@ export async function checkCurrentUserPermissions(
1052
1071
  when permission_name like 'select on pg_catalog.pg_index' then
1053
1072
  format('grant select on pg_catalog.pg_index to %I;', current_user)
1054
1073
  end
1074
+ when permission_name = 'postgres_ai schema exists' and granted = false then
1075
+ '-- run postgresai prepare-db or create the postgres_ai schema and pg_statistic view manually'
1076
+ when permission_name = 'usage on postgres_ai schema' and granted = false then
1077
+ format('grant usage on schema postgres_ai to %I;', current_user)
1055
1078
  when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
1056
1079
  '-- create postgres_ai.pg_statistic view (see setup script)'
1057
1080
  when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
@@ -1097,6 +1120,12 @@ export function formatPermissionCheckMessages(result: PreflightPermissionResult)
1097
1120
  const errors: string[] = [];
1098
1121
 
1099
1122
  for (const row of result.missingOptional) {
1123
+ if (row.permission_name === "postgres_ai schema exists") {
1124
+ warnings.push(
1125
+ "Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them."
1126
+ );
1127
+ continue;
1128
+ }
1100
1129
  const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
1101
1130
  warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
1102
1131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.16.0-rc.3",
3
+ "version": "0.16.0-rc.4",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
package/test/auth.test.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { resolve } from "path";
2
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join, resolve } from "path";
3
5
 
4
6
  import * as util from "../lib/util";
5
7
  import * as pkce from "../lib/pkce";
@@ -212,6 +214,33 @@ describe("Auth callback server", () => {
212
214
  });
213
215
 
214
216
  describe("CLI auth commands", () => {
217
+ test("cli: login --help shows all options", () => {
218
+ const r = runCli(["login", "--help"]);
219
+ expect(r.status).toBe(0);
220
+ expect(r.stdout).toMatch(/--set-key/);
221
+ expect(r.stdout).toMatch(/--debug/);
222
+ });
223
+
224
+ test("cli: login --set-key aliases auth login", () => {
225
+ const home = mkdtempSync(join(tmpdir(), "postgresai-login-"));
226
+
227
+ try {
228
+ const r = runCli(["login", "--set-key", "test-token"], {
229
+ HOME: home,
230
+ XDG_CONFIG_HOME: join(home, ".config"),
231
+ });
232
+ expect(r.status).toBe(0);
233
+ expect(r.stdout).toMatch(/API key saved/);
234
+
235
+ const saved = JSON.parse(
236
+ readFileSync(join(home, ".config", "postgresai", "config.json"), "utf8")
237
+ );
238
+ expect(saved.apiKey).toBe("test-token");
239
+ } finally {
240
+ rmSync(home, { recursive: true, force: true });
241
+ }
242
+ });
243
+
215
244
  test("cli: auth login --help shows all options", () => {
216
245
  const r = runCli(["auth", "login", "--help"]);
217
246
  expect(r.status).toBe(0);
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
14
14
  import Ajv2020 from "ajv/dist/2020";
15
15
 
16
16
  import * as checkup from "../lib/checkup";
17
+ import { checkCurrentUserPermissions } from "../lib/init";
17
18
 
18
19
  const ajv = new Ajv2020({ allErrors: true, strict: false });
19
20
  const schemasDir = resolve(import.meta.dir, "../../reporter/schemas");
@@ -191,6 +192,36 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
191
192
  // Test all checks supported by express mode
192
193
  const expressChecks = Object.keys(checkup.CHECK_INFO);
193
194
 
195
+ test("vanilla database preflight is non-fatal and full CLI output marks F004/F005 degraded", async () => {
196
+ const permissions = await checkCurrentUserPermissions(client);
197
+ expect(permissions.ok).toBe(true);
198
+ expect(permissions.missingOptional.some(
199
+ (row) => row.permission_name === "postgres_ai schema exists"
200
+ )).toBe(true);
201
+
202
+ const connString = `postgresql://postgres@localhost:${pg.port}/postgres?host=${encodeURIComponent(pg.socketDir)}`;
203
+ const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
204
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
205
+ const result = Bun.spawnSync(
206
+ [bunBin, cliPath, "checkup", connString, "--no-upload", "--json"],
207
+ { env: { ...process.env, XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" } }
208
+ );
209
+
210
+ const stderr = new TextDecoder().decode(result.stderr);
211
+ if (result.exitCode !== 0) {
212
+ throw new Error(`CLI exited ${result.exitCode}: ${stderr}`);
213
+ }
214
+ const reports = JSON.parse(new TextDecoder().decode(result.stdout));
215
+ expect(Object.keys(reports)).toHaveLength(17);
216
+ expect(stderr).toContain("optional: postgres_ai schema not found");
217
+ for (const checkId of ["F004", "F005"]) {
218
+ const dbEntry = reports[checkId].results["node-01"].data.postgres;
219
+ expect(dbEntry.status.ok).toBe(false);
220
+ expect(dbEntry.status.reason).toBe("missing_schema");
221
+ expect(dbEntry.status.error).toMatch(/schema "postgres_ai" does not exist/i);
222
+ }
223
+ }, { timeout: 60000 });
224
+
194
225
  for (const checkId of expressChecks) {
195
226
  test(`${checkId} report validates against shared schema`, async () => {
196
227
  const generator = checkup.REPORT_GENERATORS[checkId];
@@ -379,25 +410,4 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
379
410
  }
380
411
  });
381
412
 
382
- test("CLI --markdown flag works without API key", async () => {
383
- // Test that --markdown works even without an API key
384
- const connString = `postgresql://postgres@${pg.socketDir}:${pg.port}/postgres`;
385
- const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
386
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
387
-
388
- const result = Bun.spawnSync(
389
- [bunBin, cliPath, "checkup", connString, "--check-id", "H002", "--markdown", "--no-upload"],
390
- {
391
- env: {
392
- ...process.env,
393
- XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config",
394
- },
395
- }
396
- );
397
-
398
- const stderr = new TextDecoder().decode(result.stderr);
399
-
400
- // Should not complain about missing API key
401
- expect(stderr).not.toMatch(/API key is required/i);
402
- });
403
413
  });
@@ -1539,12 +1539,13 @@ describe("CLI tests", () => {
1539
1539
  const r = runCli(["checkup", "--help"]);
1540
1540
  expect(r.status).toBe(0);
1541
1541
  expect(r.stdout).toMatch(/--markdown/);
1542
- expect(r.stdout).toMatch(/output markdown to stdout/i);
1542
+ expect(r.stdout).toMatch(/PostgresAI API/i);
1543
+ expect(r.stdout).toMatch(/transmits the full\s+report JSON/i);
1543
1544
  });
1544
1545
 
1545
1546
  test("checkup --markdown is recognized as valid option", () => {
1546
1547
  // Should not produce "unknown option" error for --markdown
1547
- const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown", "--no-upload"]);
1548
+ const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"]);
1548
1549
  // Connection will fail, but option parsing should succeed
1549
1550
  expect(r.stderr).not.toMatch(/unknown option/i);
1550
1551
  expect(r.stderr).not.toMatch(/did you mean/i);
@@ -1554,11 +1555,13 @@ describe("CLI tests", () => {
1554
1555
  // Use empty config dir to ensure no API key is configured
1555
1556
  const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
1556
1557
  // --markdown should work even without API key
1557
- const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown", "--no-upload"], env);
1558
+ const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"], env);
1558
1559
  // Connection will fail, but --markdown flag should be recognized
1559
1560
  expect(r.status).not.toBe(0);
1560
1561
  expect(r.stderr).not.toMatch(/unknown option/i);
1561
1562
  expect(r.stderr).not.toMatch(/API key is required/i);
1563
+ expect(r.stderr).toMatch(/full report JSON will still be sent/i);
1564
+ expect(r.stderr).toMatch(/markdown conversion/i);
1562
1565
  });
1563
1566
 
1564
1567
  test("checkup with --no-upload and no output flags shows summary", () => {
@@ -1663,6 +1666,21 @@ describe("checkup auth pre-flight (CLI)", () => {
1663
1666
  // failure mentions this address.
1664
1667
  const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
1665
1668
 
1669
+ test("--no-upload rejects --markdown before database or API work", () => {
1670
+ const env = {
1671
+ XDG_CONFIG_HOME: `/tmp/postgresai-test-no-upload-markdown-${process.pid}`,
1672
+ PGAI_API_KEY: "configured-token",
1673
+ PGAI_API_BASE_URL: "http://127.0.0.1:1",
1674
+ };
1675
+ const r = runCli(["checkup", DEAD_DB, "--no-upload", "--markdown"], env);
1676
+
1677
+ expect(r.status).not.toBe(0);
1678
+ expect(r.stderr).toMatch(/--no-upload and --markdown are mutually exclusive/i);
1679
+ expect(r.stderr).toMatch(/markdown conversion is performed by the PostgresAI API/i);
1680
+ expect(r.stderr).toMatch(/use --json or --output/i);
1681
+ expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
1682
+ });
1683
+
1666
1684
  test("no API key: prominent notice, run continues locally", () => {
1667
1685
  const env = {
1668
1686
  XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
@@ -2173,6 +2191,31 @@ describe("checkup-summary", () => {
2173
2191
  );
2174
2192
  });
2175
2193
 
2194
+ for (const checkId of ["F004", "F005"]) {
2195
+ test(`generateCheckSummary for degraded ${checkId} is never ok`, () => {
2196
+ const report = {
2197
+ results: {
2198
+ node1: {
2199
+ data: {
2200
+ db1: {
2201
+ status: {
2202
+ ok: false,
2203
+ reason: "missing_schema",
2204
+ error: 'schema "postgres_ai" does not exist',
2205
+ },
2206
+ total_count: 0,
2207
+ },
2208
+ },
2209
+ },
2210
+ },
2211
+ };
2212
+
2213
+ const result = summary.generateCheckSummary(checkId, report);
2214
+ expect(result.status).toBe("warning");
2215
+ expect(result.message).toMatch(/degraded.*missing schema/i);
2216
+ });
2217
+ }
2218
+
2176
2219
  test("generateCheckSummary for H001 with no issues", () => {
2177
2220
  const report = {
2178
2221
  results: {
@@ -3371,6 +3414,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
3371
3414
  autovacuum_vacuum_scale_factor: expectedAutovacuumSetting,
3372
3415
  });
3373
3416
  expect(reports.F004.results["test-node"].data.testdb).toEqual({
3417
+ status: { ok: true, reason: null, error: null },
3374
3418
  bloated_tables: [],
3375
3419
  total_count: 0,
3376
3420
  total_bloat_size_bytes: 0,
@@ -3379,6 +3423,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
3379
3423
  database_size_pretty: "1.00 GiB",
3380
3424
  });
3381
3425
  expect(reports.F005.results["test-node"].data.testdb).toEqual({
3426
+ status: { ok: true, reason: null, error: null },
3382
3427
  bloated_indexes: [],
3383
3428
  total_count: 0,
3384
3429
  total_bloat_size_bytes: 0,
package/test/init.test.ts CHANGED
@@ -1489,6 +1489,24 @@ describe("checkCurrentUserPermissions", () => {
1489
1489
  ).rejects.toThrow("permission denied for relation pg_roles");
1490
1490
  });
1491
1491
 
1492
+ test("guards optional postgres_ai privilege probes when the schema is absent", async () => {
1493
+ let capturedSql = "";
1494
+ const client = {
1495
+ query: async (sql: string) => {
1496
+ capturedSql = sql;
1497
+ return { rows: [] };
1498
+ },
1499
+ };
1500
+
1501
+ await init.checkCurrentUserPermissions(client as any);
1502
+
1503
+ expect(capturedSql).toContain("to_regnamespace('postgres_ai') is null");
1504
+ expect(capturedSql).toContain("'postgres_ai schema exists' as permission_name");
1505
+ expect(capturedSql).toMatch(
1506
+ /when to_regnamespace\('postgres_ai'\) is null then null\s+when not has_schema_privilege/
1507
+ );
1508
+ });
1509
+
1492
1510
  test("returns all rows for inspection", async () => {
1493
1511
  const rows: init.PermissionCheckRow[] = [
1494
1512
  { permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
@@ -1545,6 +1563,23 @@ describe("formatPermissionCheckMessages", () => {
1545
1563
  expect(messages.errors).toHaveLength(0);
1546
1564
  });
1547
1565
 
1566
+ test("explains that a missing postgres_ai schema only degrades F004/F005", () => {
1567
+ const result: init.PreflightPermissionResult = {
1568
+ ok: true,
1569
+ rows: [],
1570
+ missingRequired: [],
1571
+ missingOptional: [
1572
+ { permission_name: "postgres_ai schema exists", status: "optional", granted: false, fix_command: null },
1573
+ ],
1574
+ };
1575
+
1576
+ const messages = init.formatPermissionCheckMessages(result);
1577
+ expect(messages.failed).toBe(false);
1578
+ expect(messages.warnings).toEqual([
1579
+ "Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them.",
1580
+ ]);
1581
+ });
1582
+
1548
1583
  test("returns errors with fix commands for missing required permissions", () => {
1549
1584
  const result: init.PreflightPermissionResult = {
1550
1585
  ok: false,
@@ -99,6 +99,46 @@ describe("Schema validation", () => {
99
99
  validateAgainstSchema(report, "F003");
100
100
  });
101
101
 
102
+ for (const checkId of ["F004", "F005"]) {
103
+ test(`${checkId} distinguishes a healthy empty result from missing schema`, async () => {
104
+ const healthyClient = createMockClient();
105
+ const healthy = await checkup.REPORT_GENERATORS[checkId](healthyClient as any, "node-01");
106
+ const healthyDb = healthy.results["node-01"].data.testdb;
107
+ expect(healthyDb.status).toEqual({ ok: true, reason: null, error: null });
108
+ validateAgainstSchema(healthy, checkId);
109
+
110
+ const missingSchemaClient = createMockClient({
111
+ bloatCapabilityRows: [
112
+ { schema_exists: false, schema_usage: false, view_exists: false, view_select: false },
113
+ ],
114
+ });
115
+ const degraded = await checkup.REPORT_GENERATORS[checkId](missingSchemaClient as any, "node-01");
116
+ const degradedDb = degraded.results["node-01"].data.testdb;
117
+ expect(degradedDb.status).toEqual({
118
+ ok: false,
119
+ reason: "missing_schema",
120
+ error: 'schema "postgres_ai" does not exist',
121
+ });
122
+ validateAgainstSchema(degraded, checkId);
123
+ });
124
+
125
+ test(`${checkId} exposes missing-grant degradation`, async () => {
126
+ const client = createMockClient({
127
+ bloatCapabilityRows: [
128
+ { schema_exists: true, schema_usage: true, view_exists: true, view_select: false },
129
+ ],
130
+ });
131
+ const report = await checkup.REPORT_GENERATORS[checkId](client as any, "node-01");
132
+ const dbEntry = report.results["node-01"].data.testdb;
133
+ expect(dbEntry.status).toEqual({
134
+ ok: false,
135
+ reason: "missing_grant",
136
+ error: "permission denied for relation postgres_ai.pg_statistic",
137
+ });
138
+ validateAgainstSchema(report, checkId);
139
+ });
140
+ }
141
+
102
142
  // Settings reports (D004, F001, G001) - single test each
103
143
  for (const checkId of ["D004", "F001", "G001"]) {
104
144
  test(`${checkId} validates against schema`, async () => {
@@ -19,6 +19,7 @@ export interface MockClientOptions {
19
19
  indexBloatRows?: any[];
20
20
  deadTuplesRows?: any[];
21
21
  vacuumStatsRows?: any[];
22
+ bloatCapabilityRows?: any[];
22
23
  deadlockStatsRows?: any[];
23
24
  pgStatStatementsExtensionRows?: any[];
24
25
  pgStatStatementsStatsRows?: any[];
@@ -61,6 +62,7 @@ export function createMockClient(options: MockClientOptions = {}) {
61
62
  indexBloatRows = [],
62
63
  deadTuplesRows = [],
63
64
  vacuumStatsRows = [],
65
+ bloatCapabilityRows = [{ schema_exists: true, schema_usage: true, view_exists: true, view_select: true }],
64
66
  deadlockStatsRows = [{ deadlocks: "0", conflicts: "0", stats_reset: null }],
65
67
  pgStatStatementsExtensionRows = [],
66
68
  pgStatStatementsStatsRows = [],
@@ -128,6 +130,9 @@ export function createMockClient(options: MockClientOptions = {}) {
128
130
  return { rows: deadTuplesRows };
129
131
  }
130
132
  // F004/F005: bloat metrics from metrics.yml
133
+ if (sql.includes("to_regnamespace('postgres_ai')") && sql.includes("view_select")) {
134
+ return { rows: bloatCapabilityRows };
135
+ }
131
136
  if (sql.includes("tag_idxname") && sql.includes("bloat_size")) {
132
137
  return { rows: indexBloatRows };
133
138
  }