postgresai 0.15.0 → 0.16.0-rc.0

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.
@@ -13334,9 +13334,11 @@ function isHtmlContent2(text) {
13334
13334
  function formatHttpError2(operation, status, responseBody) {
13335
13335
  const statusMessage = HTTP_STATUS_MESSAGES2[status] || "Request failed";
13336
13336
  let errMsg = `${operation}: HTTP ${status} - ${statusMessage}`;
13337
+ const remediation = status === 401 ? `
13338
+ ${AUTH_REMEDIATION_HINT2}` : "";
13337
13339
  if (responseBody) {
13338
13340
  if (isHtmlContent2(responseBody)) {
13339
- return errMsg;
13341
+ return errMsg + remediation;
13340
13342
  }
13341
13343
  try {
13342
13344
  const errObj = JSON.parse(responseBody);
@@ -13356,7 +13358,7 @@ ${trimmed}`;
13356
13358
  }
13357
13359
  }
13358
13360
  }
13359
- return errMsg;
13361
+ return errMsg + remediation;
13360
13362
  }
13361
13363
  function maskSecret2(secret) {
13362
13364
  if (!secret)
@@ -13389,7 +13391,7 @@ function resolveBaseUrls2(opts, cfg, defaults2 = {}) {
13389
13391
  storageBaseUrl: normalizeBaseUrl2(storageCandidate)
13390
13392
  };
13391
13393
  }
13392
- var HTTP_STATUS_MESSAGES2;
13394
+ var HTTP_STATUS_MESSAGES2, AUTH_REMEDIATION_HINT2 = "Run 'postgresai auth' to (re)authenticate, or set/update PGAI_API_KEY.";
13393
13395
  var init_util = __esm(() => {
13394
13396
  HTTP_STATUS_MESSAGES2 = {
13395
13397
  400: "Bad Request",
@@ -13423,7 +13425,7 @@ var {
13423
13425
  // package.json
13424
13426
  var package_default = {
13425
13427
  name: "postgresai",
13426
- version: "0.15.0",
13428
+ version: "0.16.0-rc.0",
13427
13429
  description: "postgres_ai CLI",
13428
13430
  license: "Apache-2.0",
13429
13431
  private: false,
@@ -16254,7 +16256,7 @@ var Result = import_lib.default.Result;
16254
16256
  var TypeOverrides = import_lib.default.TypeOverrides;
16255
16257
  var defaults = import_lib.default.defaults;
16256
16258
  // package.json
16257
- var version = "0.15.0";
16259
+ var version = "0.16.0-rc.0";
16258
16260
  var package_default2 = {
16259
16261
  name: "postgresai",
16260
16262
  version,
@@ -16395,12 +16397,15 @@ function isHtmlContent(text) {
16395
16397
  const trimmed = text.trim();
16396
16398
  return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
16397
16399
  }
16400
+ var AUTH_REMEDIATION_HINT = "Run 'postgresai auth' to (re)authenticate, or set/update PGAI_API_KEY.";
16398
16401
  function formatHttpError(operation, status, responseBody) {
16399
16402
  const statusMessage = HTTP_STATUS_MESSAGES[status] || "Request failed";
16400
16403
  let errMsg = `${operation}: HTTP ${status} - ${statusMessage}`;
16404
+ const remediation = status === 401 ? `
16405
+ ${AUTH_REMEDIATION_HINT}` : "";
16401
16406
  if (responseBody) {
16402
16407
  if (isHtmlContent(responseBody)) {
16403
- return errMsg;
16408
+ return errMsg + remediation;
16404
16409
  }
16405
16410
  try {
16406
16411
  const errObj = JSON.parse(responseBody);
@@ -16420,7 +16425,7 @@ ${trimmed}`;
16420
16425
  }
16421
16426
  }
16422
16427
  }
16423
- return errMsg;
16428
+ return errMsg + remediation;
16424
16429
  }
16425
16430
  function maskSecret(secret) {
16426
16431
  if (!secret)
@@ -27689,7 +27694,7 @@ where
27689
27694
  pg_invalid_indexes: {
27690
27695
  description: "This metric identifies invalid indexes in the database with decision tree data for remediation. It provides insights into whether to DROP (if duplicate exists), RECREATE (if backs constraint), or flag as UNCERTAIN (if additional RCA is needed to check query plans). Decision tree: 1) Valid duplicate exists -> DROP, 2) Backs PK/UNIQUE constraint -> RECREATE, 3) Table < 10K rows -> RECREATE (small tables rebuild quickly, typically under 1 second), 4) Otherwise -> UNCERTAIN (need query plan analysis to assess impact). Adapts the top-N + `'$other$'` bucket pattern from !262 to this metric: ranks invalid indexes by `index_size_bytes desc` (ties broken by schema, table, then index name for stability), keeps the top 100, and folds the tail into a single `'$other$'` row whose `index_size_bytes` / `table_row_estimate` are summed and whose tag columns carry the literal `'$other$'` sentinel. The `'$other$'` row is omitted entirely (via `HAVING count(*) > 0`) when all invalid indexes fit within the top-100 cap, so its absence on healthy clusters is normal.",
27691
27696
  sqls: {
27692
- 11: `with fk_indexes as ( /* pgwatch_generated */
27697
+ 11: `with fk_indexes as materialized ( /* pgwatch_generated */
27693
27698
  select
27694
27699
  schemaname as schema_name,
27695
27700
  indexrelid,
@@ -27815,7 +27820,7 @@ having count(*) > 0;
27815
27820
  unused_indexes: {
27816
27821
  description: "This metric identifies unused indexes in the database. It provides insights into the number of unused indexes and their details. This metric helps administrators identify and fix unused indexes to improve database performance. Adapts the top-N + `'$other$'` bucket pattern from !262 to this metric: within the `idx_scan = 0 AND idx_is_btree` filter, ranks indexes by `index_size_bytes desc` (ties broken by schema, table, index name), keeps the top 100, and folds the tail into a single `'$other$'` row. Counter columns (`idx_scan`, `all_scans`, `writes`, `index_size_bytes`, `table_size_bytes`, `relpages`) are summed across the tail; ratio columns (`index_scan_pct`, `scans_per_write`) and the `supports_fk` boolean are deliberately zeroed/false on the aggregate row because the tail-level average would mislead and the per-row FK relationship has no meaningful aggregate. Tag columns carry the literal `'$other$'` sentinel. The `'$other$'` row is omitted entirely (via `HAVING count(*) > 0`) when ≤100 indexes match the unused filter.",
27817
27822
  sqls: {
27818
- 11: `with fk_indexes as ( /* pgwatch_generated */
27823
+ 11: `with fk_indexes as materialized ( /* pgwatch_generated */
27819
27824
  select
27820
27825
  n.nspname as schema_name,
27821
27826
  ci.relname as index_name,
@@ -27956,7 +27961,7 @@ having count(*) > 0;
27956
27961
  redundant_indexes: {
27957
27962
  description: "This metric identifies redundant indexes that can potentially be dropped to save storage space and improve write performance. It analyzes index relationships and finds indexes that are covered by other indexes, considering column order, operator classes, and foreign key constraints. Uses the exact logic from tmp.sql with JSON aggregation and proper thresholds. Adapts the top-N + `'$other$'` bucket pattern from !262 to this metric: ranks redundant indexes by `index_size_bytes desc` (ties broken by `table_name`), keeps the top 100, and folds the tail into a single `'$other$'` row whose `table_size_bytes`, `index_size_bytes` and `index_usage` columns are summed and whose tag columns carry the literal `'$other$'` sentinel. The `redundant_indexes_grouped` CTE intentionally preserves duplicate column aliases (`tag_schema_name` / `tag_index_name` appear twice — once from the raw name and once from the `formated_*` variant) because the dashboards rely on both spellings; the duplication is preserved on the `'$other$'` row for consistency. The `'$other$'` row is omitted entirely (via `HAVING count(*) > 0`) when there are ≤100 redundant pairs, so its absence on healthy clusters is normal.",
27958
27963
  sqls: {
27959
- 11: `with fk_indexes as ( /* pgwatch_generated */
27964
+ 11: `with fk_indexes as materialized ( /* pgwatch_generated */
27960
27965
  select
27961
27966
  n.nspname as schema_name,
27962
27967
  ci.relname as index_name,
@@ -28163,6 +28168,64 @@ where datname = current_database()
28163
28168
  gauges: ["stats_reset_epoch", "seconds_since_reset"],
28164
28169
  statement_timeout_seconds: 15
28165
28170
  },
28171
+ pg_dead_tuples: {
28172
+ description: "Per-table dead-tuple accumulation and per-table autovacuum overrides for the F003 express check (\"Autovacuum: dead tuples\"). Reads the live counters in pg_stat_user_tables joined to pg_class for reloptions, so dead tuples that have NEVER been vacuumed are visible — unlike the statistical bloat estimators (F004/F005), which completely miss a table where autovacuum is disabled and dead tuples pile up unvacuumed. Returns relations that either carry dead tuples or have autovacuum disabled per-table, bounded to the top 100 by n_dead_tup so pathological schemas can't blow up the report. autovacuum_disabled recognises every boolean spelling Postgres accepts and stores verbatim in reloptions (false/off/no/0 and their unique prefixes; confirmed against PG13 and PG16 that `alter table .. set (autovacuum_enabled = false|off|0)` stores the literal text, so matching only 'autovacuum_enabled=off' misses the common '=false' spelling). Timestamps are exported as epoch seconds (0 = never) because Prometheus gauges must be numeric. The AccessExclusiveLock guard mirrors table_stats: pg_table_size() takes a lock and would block behind a fully locked relation. Compatible with all supported Postgres versions.",
28173
+ sqls: {
28174
+ 11: `with dead_tuples as ( /* pgwatch_generated */
28175
+ select
28176
+ s.schemaname,
28177
+ s.relname,
28178
+ s.n_live_tup,
28179
+ s.n_dead_tup,
28180
+ coalesce(
28181
+ round(100 * s.n_dead_tup::numeric / nullif(s.n_live_tup + s.n_dead_tup, 0), 2),
28182
+ 0
28183
+ )::float as dead_pct,
28184
+ extract(epoch from greatest(s.last_autovacuum, '1970-01-01Z'))::int8 as last_autovacuum,
28185
+ extract(epoch from greatest(s.last_vacuum, '1970-01-01Z'))::int8 as last_vacuum,
28186
+ s.autovacuum_count,
28187
+ s.vacuum_count,
28188
+ case when exists (
28189
+ select 1
28190
+ from unnest(coalesce(c.reloptions, '{}'::text[])) as opt
28191
+ where lower(opt) ~ '^autovacuum_enabled=(false|fals|fal|fa|f|no|n|off|of|0)$'
28192
+ ) then 1 else 0 end as autovacuum_disabled,
28193
+ pg_table_size(c.oid) as table_size_b
28194
+ from pg_stat_user_tables s
28195
+ join pg_class c on c.oid = s.relid
28196
+ where not exists (
28197
+ select 1 from pg_locks
28198
+ where relation = s.relid and mode = 'AccessExclusiveLock'
28199
+ )
28200
+ ), ranked as (
28201
+ select
28202
+ row_number() over (
28203
+ order by n_dead_tup desc, schemaname, relname
28204
+ ) as rownum,
28205
+ *
28206
+ from dead_tuples
28207
+ where n_dead_tup > 0 or autovacuum_disabled = 1
28208
+ )
28209
+ select
28210
+ current_database() as tag_datname,
28211
+ schemaname as tag_schemaname,
28212
+ relname as tag_relname,
28213
+ n_live_tup,
28214
+ n_dead_tup,
28215
+ dead_pct,
28216
+ last_autovacuum,
28217
+ last_vacuum,
28218
+ autovacuum_count,
28219
+ vacuum_count,
28220
+ autovacuum_disabled,
28221
+ table_size_b
28222
+ from ranked
28223
+ where rownum <= 100
28224
+ `
28225
+ },
28226
+ gauges: ["n_live_tup", "n_dead_tup", "dead_pct", "last_autovacuum", "last_vacuum", "autovacuum_count", "vacuum_count", "autovacuum_disabled", "table_size_b"],
28227
+ statement_timeout_seconds: 15
28228
+ },
28166
28229
  pg_table_bloat: {
28167
28230
  description: "Estimated per-table bloat (heap pages allocated vs heap pages needed at perfect packing), bounded to the top 100 per database. Adapts the top-N + `'$other$'` bucket pattern from !262: everything below the cap is summed into a single `'$other$'` row so dashboard \"total bloat across the DB\" stays correct even when the tail is large. Ranks by `bloat_pct` descending (most-bloated tables first), with `is_na = 0` preferred (don't crowd top-N with tables whose estimate is unreliable) and stable schemaname/tblname tiebreakers. Preserves the existing >1 MiB filter (zero-byte and tiny tables aren't interesting for bloat). Aggregate semantics on the `'$other$'` row: sum for real_size_mib / extra_size / bloat_size (total wasted bytes in the tail); recompute extra_pct and bloat_pct from the summed numerator/denominator (weighted-avg effectively); avg(fillfactor); max(is_na) (any tail row with bad stats taints the aggregate). The `'$other$'` sentinel cannot collide with a real Postgres identifier.",
28168
28231
  sqls: {
@@ -28477,8 +28540,8 @@ group by
28477
28540
  (sum(coalesce(write_bytes, 0)) / 1048576.0)::int8 as write_bytes_mb,
28478
28541
  sum(coalesce(write_time, 0))::int8 as write_time_ms,
28479
28542
  sum(coalesce(writebacks, 0))::int8 as writebacks,
28480
- -- PostgreSQL 18 has no writeback_bytes column; rows with NULL op_bytes contribute zero by design.
28481
- (sum(coalesce(writebacks, 0) * coalesce(op_bytes, 0)) / 1048576.0)::int8 as writeback_bytes_mb,
28543
+ -- PostgreSQL 18 removed the per-operation byte size column and exposes no writeback byte counts, so this is always 0.
28544
+ 0::int8 as writeback_bytes_mb,
28482
28545
  sum(coalesce(writeback_time, 0))::int8 as writeback_time_ms,
28483
28546
  sum(coalesce(fsyncs, 0))::int8 as fsyncs,
28484
28547
  sum(coalesce(fsync_time, 0))::int8 as fsync_time_ms,
@@ -28515,6 +28578,7 @@ var METRIC_NAMES = {
28515
28578
  H001: "pg_invalid_indexes",
28516
28579
  H002: "unused_indexes",
28517
28580
  H004: "redundant_indexes",
28581
+ F003: "pg_dead_tuples",
28518
28582
  F004: "pg_table_bloat",
28519
28583
  F005: "pg_btree_bloat",
28520
28584
  settings: "settings",
@@ -28809,46 +28873,6 @@ var CHECKUP_DICTIONARY_DATA = [
28809
28873
  sort_order: null,
28810
28874
  is_system_report: false
28811
28875
  },
28812
- {
28813
- code: "F001",
28814
- title: "Autovacuum: current settings",
28815
- description: "Autovacuum configuration",
28816
- category: "vacuum",
28817
- sort_order: null,
28818
- is_system_report: false
28819
- },
28820
- {
28821
- code: "F002",
28822
- title: "Autovacuum: transaction ID wraparound",
28823
- description: "XID age and wraparound risk",
28824
- category: "vacuum",
28825
- sort_order: null,
28826
- is_system_report: false
28827
- },
28828
- {
28829
- code: "F003",
28830
- title: "Autovacuum: dead tuples",
28831
- description: "Dead tuple counts and cleanup status",
28832
- category: "vacuum",
28833
- sort_order: null,
28834
- is_system_report: false
28835
- },
28836
- {
28837
- code: "F004",
28838
- title: "Autovacuum: heap bloat estimate",
28839
- description: "Estimated table bloat",
28840
- category: "vacuum",
28841
- sort_order: null,
28842
- is_system_report: false
28843
- },
28844
- {
28845
- code: "F005",
28846
- title: "Autovacuum: index bloat estimate",
28847
- description: "Estimated index bloat",
28848
- category: "vacuum",
28849
- sort_order: null,
28850
- is_system_report: false
28851
- },
28852
28876
  {
28853
28877
  code: "F006",
28854
28878
  title: "Precise heap bloat analysis",
@@ -28865,14 +28889,6 @@ var CHECKUP_DICTIONARY_DATA = [
28865
28889
  sort_order: null,
28866
28890
  is_system_report: false
28867
28891
  },
28868
- {
28869
- code: "F008",
28870
- title: "Autovacuum: resource usage",
28871
- description: "Autovacuum I/O and CPU impact",
28872
- category: "vacuum",
28873
- sort_order: null,
28874
- is_system_report: false
28875
- },
28876
28892
  {
28877
28893
  code: "G001",
28878
28894
  title: "Memory-related settings",
@@ -29088,6 +29104,54 @@ var CHECKUP_DICTIONARY_DATA = [
29088
29104
  category: "waits",
29089
29105
  sort_order: null,
29090
29106
  is_system_report: false
29107
+ },
29108
+ {
29109
+ code: "F001",
29110
+ title: "Autovacuum settings",
29111
+ description: "Autovacuum configuration",
29112
+ category: "vacuum",
29113
+ sort_order: null,
29114
+ is_system_report: false
29115
+ },
29116
+ {
29117
+ code: "F002",
29118
+ title: "Autovacuum transaction ID wraparound",
29119
+ description: "XID age and wraparound risk",
29120
+ category: "vacuum",
29121
+ sort_order: null,
29122
+ is_system_report: false
29123
+ },
29124
+ {
29125
+ code: "F003",
29126
+ title: "Autovacuum dead tuples",
29127
+ description: "Dead tuple counts and cleanup status",
29128
+ category: "vacuum",
29129
+ sort_order: null,
29130
+ is_system_report: false
29131
+ },
29132
+ {
29133
+ code: "F004",
29134
+ title: "Autovacuum heap bloat estimate",
29135
+ description: "Estimated table bloat",
29136
+ category: "vacuum",
29137
+ sort_order: null,
29138
+ is_system_report: false
29139
+ },
29140
+ {
29141
+ code: "F005",
29142
+ title: "Autovacuum index bloat estimate",
29143
+ description: "Estimated index bloat",
29144
+ category: "vacuum",
29145
+ sort_order: null,
29146
+ is_system_report: false
29147
+ },
29148
+ {
29149
+ code: "F008",
29150
+ title: "Autovacuum resource usage",
29151
+ description: "Autovacuum I/O and CPU impact",
29152
+ category: "vacuum",
29153
+ sort_order: null,
29154
+ is_system_report: false
29091
29155
  }
29092
29156
  ];
29093
29157
 
@@ -29109,6 +29173,9 @@ var SECONDS_PER_MINUTE = 60;
29109
29173
  function toBool(val) {
29110
29174
  return val === true || val === 1 || val === "t" || val === "true";
29111
29175
  }
29176
+ var F003_DEAD_TUPLES_MIN = 1e5;
29177
+ var F003_DEAD_PCT_MIN = 20;
29178
+ var F003_AUTOVACUUM_DISABLED_MIN_ROWS = 1e4;
29112
29179
  function parseVersionNum(versionNum) {
29113
29180
  if (!versionNum || versionNum.length < 6) {
29114
29181
  return { major: "", minor: "" };
@@ -29515,6 +29582,58 @@ async function getRedundantIndexes(client, pgMajorVersion = 16) {
29515
29582
  return result2;
29516
29583
  });
29517
29584
  }
29585
+ async function getDeadTuples(client, pgMajorVersion = 16) {
29586
+ const sql = getMetricSql(METRIC_NAMES.F003, pgMajorVersion);
29587
+ const result = await client.query(sql);
29588
+ return result.rows.map((row) => {
29589
+ const t = transformMetricRow(row);
29590
+ const nLive = parseInt(String(t.n_live_tup || 0), 10);
29591
+ const nDead = parseInt(String(t.n_dead_tup || 0), 10);
29592
+ const deadPct = parseFloat(String(t.dead_pct)) || 0;
29593
+ const lastAutovacuumEpoch = parseInt(String(t.last_autovacuum || 0), 10);
29594
+ const lastVacuumEpoch = parseInt(String(t.last_vacuum || 0), 10);
29595
+ const autovacuumDisabled = parseInt(String(t.autovacuum_disabled || 0), 10) === 1 || toBool(t.autovacuum_disabled);
29596
+ const tableSizeBytes = parseInt(String(t.table_size_b || 0), 10);
29597
+ return {
29598
+ schema_name: String(t.schemaname || ""),
29599
+ table_name: String(t.relname || ""),
29600
+ n_live_tup: nLive,
29601
+ n_dead_tup: nDead,
29602
+ dead_pct: deadPct,
29603
+ last_autovacuum: lastAutovacuumEpoch > 0 ? new Date(lastAutovacuumEpoch * 1000).toISOString() : null,
29604
+ last_autovacuum_epoch: lastAutovacuumEpoch,
29605
+ last_vacuum: lastVacuumEpoch > 0 ? new Date(lastVacuumEpoch * 1000).toISOString() : null,
29606
+ last_vacuum_epoch: lastVacuumEpoch,
29607
+ autovacuum_count: parseInt(String(t.autovacuum_count || 0), 10),
29608
+ vacuum_count: parseInt(String(t.vacuum_count || 0), 10),
29609
+ autovacuum_disabled: autovacuumDisabled,
29610
+ table_size_bytes: tableSizeBytes,
29611
+ table_size_pretty: formatBytes(tableSizeBytes),
29612
+ exceeds_dead_tuple_thresholds: nDead >= F003_DEAD_TUPLES_MIN && deadPct >= F003_DEAD_PCT_MIN,
29613
+ autovacuum_disabled_flagged: autovacuumDisabled && nLive + nDead >= F003_AUTOVACUUM_DISABLED_MIN_ROWS
29614
+ };
29615
+ });
29616
+ }
29617
+ function buildDeadTuplesConclusions(tables) {
29618
+ const conclusions = [];
29619
+ const recommendations = [];
29620
+ const fmt = (n) => n.toLocaleString("en-US");
29621
+ for (const t of tables) {
29622
+ const rel = `"${t.schema_name}"."${t.table_name}"`;
29623
+ const lastAv = t.last_autovacuum ? `last autovacuum: ${t.last_autovacuum}` : "autovacuum has never vacuumed it";
29624
+ if (t.exceeds_dead_tuple_thresholds && t.autovacuum_disabled) {
29625
+ conclusions.push(`Table ${rel} has ${fmt(t.n_dead_tup)} dead tuples (${t.dead_pct}% of all tuples) ` + `and autovacuum is disabled on it via reloptions (${lastAv}).`);
29626
+ recommendations.push(`Re-enable autovacuum on ${rel}: alter table ${rel} reset (autovacuum_enabled); ` + `then run: vacuum (analyze) ${rel}; to clean up the accumulated dead tuples.`);
29627
+ } else if (t.exceeds_dead_tuple_thresholds) {
29628
+ conclusions.push(`Table ${rel} has ${fmt(t.n_dead_tup)} dead tuples (${t.dead_pct}% of all tuples; ${lastAv}).`);
29629
+ recommendations.push(`Run: vacuum (analyze) ${rel}; and review autovacuum settings ` + `(autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_max_workers) ` + `if dead tuples keep accumulating on ${rel}.`);
29630
+ } else if (t.autovacuum_disabled_flagged) {
29631
+ conclusions.push(`Autovacuum is disabled via reloptions on table ${rel} ` + `(~${fmt(t.n_live_tup + t.n_dead_tup)} tuples); dead tuples and transaction ID age ` + `will accumulate unchecked.`);
29632
+ recommendations.push(`Re-enable autovacuum on ${rel}: alter table ${rel} reset (autovacuum_enabled); ` + `unless this table is managed by a carefully scheduled manual vacuum job.`);
29633
+ }
29634
+ }
29635
+ return { conclusions, recommendations };
29636
+ }
29518
29637
  function createBaseReport(checkId, checkTitle, nodeName) {
29519
29638
  const buildTs = resolveBuildTs();
29520
29639
  return {
@@ -29778,6 +29897,40 @@ async function generateF001(client, nodeName) {
29778
29897
  };
29779
29898
  return report;
29780
29899
  }
29900
+ async function generateF003(client, nodeName) {
29901
+ const report = createBaseReport("F003", "Autovacuum: dead tuples", nodeName);
29902
+ const postgresVersion = await getPostgresVersion(client);
29903
+ const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
29904
+ const tables = await getDeadTuples(client, pgMajorVersion);
29905
+ const { datname: dbName, size_bytes: dbSizeBytes } = await getCurrentDatabaseInfo(client, pgMajorVersion);
29906
+ const flaggedCount = tables.filter((t) => t.exceeds_dead_tuple_thresholds).length;
29907
+ const autovacuumDisabledCount = tables.filter((t) => t.autovacuum_disabled).length;
29908
+ const autovacuumDisabledFlaggedCount = tables.filter((t) => t.autovacuum_disabled_flagged).length;
29909
+ const totalDeadTuples = tables.reduce((sum, t) => sum + t.n_dead_tup, 0);
29910
+ const { conclusions, recommendations } = buildDeadTuplesConclusions(tables);
29911
+ const dbEntry = {
29912
+ dead_tuples_tables: tables,
29913
+ total_count: tables.length,
29914
+ flagged_count: flaggedCount,
29915
+ autovacuum_disabled_count: autovacuumDisabledCount,
29916
+ autovacuum_disabled_flagged_count: autovacuumDisabledFlaggedCount,
29917
+ total_dead_tuples: totalDeadTuples,
29918
+ thresholds: {
29919
+ dead_tuples_min: F003_DEAD_TUPLES_MIN,
29920
+ dead_pct_min: F003_DEAD_PCT_MIN,
29921
+ autovacuum_disabled_min_rows: F003_AUTOVACUUM_DISABLED_MIN_ROWS
29922
+ },
29923
+ conclusions,
29924
+ recommendations,
29925
+ database_size_bytes: dbSizeBytes,
29926
+ database_size_pretty: formatBytes(dbSizeBytes)
29927
+ };
29928
+ report.results[nodeName] = {
29929
+ data: { [dbName]: dbEntry },
29930
+ postgres_version: postgresVersion
29931
+ };
29932
+ return report;
29933
+ }
29781
29934
  async function generateF004(client, nodeName) {
29782
29935
  const report = createBaseReport("F004", "Autovacuum: heap bloat (estimated)", nodeName);
29783
29936
  const postgresVersion = await getPostgresVersion(client);
@@ -30211,6 +30364,7 @@ var REPORT_GENERATORS = {
30211
30364
  D001: generateD001,
30212
30365
  D004: generateD004,
30213
30366
  F001: generateF001,
30367
+ F003: generateF003,
30214
30368
  F004: generateF004,
30215
30369
  F005: generateF005,
30216
30370
  G001: generateG001,
@@ -30452,6 +30606,45 @@ async function postRpc(params) {
30452
30606
  req.end();
30453
30607
  });
30454
30608
  }
30609
+ var VERIFY_API_KEY_TIMEOUT_MS = 1e4;
30610
+ async function verifyApiKey(params) {
30611
+ const { apiKey, apiBaseUrl, timeoutMs = VERIFY_API_KEY_TIMEOUT_MS } = params;
30612
+ const base = normalizeBaseUrl(apiBaseUrl);
30613
+ const url = new URL3(`${base}/checkup_reports`);
30614
+ url.searchParams.set("limit", "1");
30615
+ if (url.protocol === "http:") {
30616
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
30617
+ const isLoopback = ["localhost", "127.0.0.1", "::1"].includes(hostname);
30618
+ if (!isLoopback && process.env.CHECKUP_ALLOW_HTTP !== "1") {
30619
+ return {
30620
+ status: "unknown",
30621
+ detail: `refusing to send API key over plaintext HTTP to '${url.host}'`
30622
+ };
30623
+ }
30624
+ }
30625
+ const controller = new AbortController;
30626
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
30627
+ try {
30628
+ const response = await fetch(url.toString(), {
30629
+ method: "GET",
30630
+ headers: { "access-token": apiKey },
30631
+ signal: controller.signal
30632
+ });
30633
+ await response.text().catch(() => "");
30634
+ if (response.status === 401 || response.status === 403) {
30635
+ return { status: "invalid", statusCode: response.status };
30636
+ }
30637
+ if (response.ok) {
30638
+ return { status: "valid" };
30639
+ }
30640
+ return { status: "unknown", detail: `HTTP ${response.status}` };
30641
+ } catch (err) {
30642
+ const message = err instanceof Error ? err.message : String(err);
30643
+ return { status: "unknown", detail: message };
30644
+ } finally {
30645
+ clearTimeout(timer);
30646
+ }
30647
+ }
30455
30648
  async function createCheckupReport(params) {
30456
30649
  const { apiKey, apiBaseUrl, project, status } = params;
30457
30650
  const bodyObj = {
@@ -30544,6 +30737,8 @@ function generateCheckSummary(checkId, report) {
30544
30737
  return summarizeD004(nodeData);
30545
30738
  case "F001":
30546
30739
  return summarizeF001(nodeData);
30740
+ case "F003":
30741
+ return summarizeF003(nodeData);
30547
30742
  case "G001":
30548
30743
  return summarizeG001(nodeData);
30549
30744
  case "G003":
@@ -30708,6 +30903,27 @@ function summarizeF001(nodeData) {
30708
30903
  message: `${settingsCount} autovacuum setting${settingsCount > 1 ? "s" : ""} collected`
30709
30904
  };
30710
30905
  }
30906
+ function summarizeF003(nodeData) {
30907
+ const data = nodeData?.data || {};
30908
+ let flaggedCount = 0;
30909
+ let disabledCount = 0;
30910
+ for (const dbData of Object.values(data)) {
30911
+ const dbEntry = dbData;
30912
+ flaggedCount += dbEntry.flagged_count || 0;
30913
+ disabledCount += dbEntry.autovacuum_disabled_flagged_count || 0;
30914
+ }
30915
+ if (flaggedCount === 0 && disabledCount === 0) {
30916
+ return { status: "ok", message: "No significant dead tuple accumulation" };
30917
+ }
30918
+ const parts = [];
30919
+ if (flaggedCount > 0) {
30920
+ parts.push(`${flaggedCount} table${flaggedCount > 1 ? "s" : ""} with excessive dead tuples`);
30921
+ }
30922
+ if (disabledCount > 0) {
30923
+ parts.push(`${disabledCount} table${disabledCount > 1 ? "s" : ""} with autovacuum disabled`);
30924
+ }
30925
+ return { status: "warning", message: parts.join(", ") };
30926
+ }
30711
30927
  function summarizeG001(nodeData) {
30712
30928
  const data = nodeData?.data || {};
30713
30929
  const settingsCount = Object.keys(data).length;
@@ -33720,6 +33936,9 @@ function prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyReque
33720
33936
  console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
33721
33937
  return null;
33722
33938
  }
33939
+ console.error("Notice: no API key configured \u2014 results will NOT be uploaded to PostgresAI.");
33940
+ console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
33941
+ console.error(" To run locally without this notice, pass --no-upload.");
33723
33942
  return;
33724
33943
  }
33725
33944
  const cfg = readConfig();
@@ -35010,6 +35229,22 @@ Usage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname
35010
35229
  const uploadCfg = uploadResult?.config;
35011
35230
  const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
35012
35231
  shouldUpload = !!uploadCfg;
35232
+ if (uploadCfg) {
35233
+ const verification = await verifyApiKey({
35234
+ apiKey: uploadCfg.apiKey,
35235
+ apiBaseUrl: uploadCfg.apiBaseUrl
35236
+ });
35237
+ if (verification.status === "invalid") {
35238
+ console.error(`Error: the configured API key was rejected by the PostgresAI API (HTTP ${verification.statusCode})`);
35239
+ console.error("Tip: run 'postgresai auth login' to re-authenticate, or pass a valid --api-key / set PGAI_API_KEY");
35240
+ console.error("Tip: pass --no-upload to run checks locally without uploading");
35241
+ process.exitCode = 1;
35242
+ return;
35243
+ }
35244
+ if (verification.status === "unknown") {
35245
+ console.error(`Warning: could not verify API key before running checks (${verification.detail}); continuing \u2014 upload will still be attempted`);
35246
+ }
35247
+ }
35013
35248
  const adminConn = resolveAdminConnection({
35014
35249
  conn,
35015
35250
  envPassword: process.env.PGPASSWORD
@@ -35233,42 +35468,79 @@ function checkRunningContainers() {
35233
35468
  return { running: false, containers: [] };
35234
35469
  }
35235
35470
  }
35236
- function registerMonitoringInstance(apiKey, projectName, opts) {
35471
+ async function registerMonitoringInstance(apiKey, projectName, opts) {
35237
35472
  const { apiBaseUrl } = resolveBaseUrls2(opts);
35238
35473
  const url = `${apiBaseUrl}/rpc/monitoring_instance_register`;
35239
35474
  const debug = opts?.debug;
35475
+ const instanceId = opts?.instanceId;
35476
+ const retries = opts?.retries ?? (instanceId ? 1 : 0);
35477
+ const retryDelayMs = opts?.retryDelayMs ?? 400;
35240
35478
  if (debug) {
35241
35479
  console.error(`
35242
35480
  Debug: Registering monitoring instance...`);
35243
35481
  console.error(`Debug: POST ${url}`);
35244
- console.error(`Debug: project_name=${projectName}`);
35482
+ console.error(`Debug: project_name=${projectName}${instanceId ? ` instance_id=${instanceId}` : ""}`);
35245
35483
  }
35246
- fetch(url, {
35247
- method: "POST",
35248
- headers: {
35249
- "Content-Type": "application/json"
35250
- },
35251
- body: JSON.stringify({
35252
- api_token: apiKey,
35253
- project_name: projectName
35254
- })
35255
- }).then(async (res) => {
35256
- const body = await res.text().catch(() => "");
35257
- if (!res.ok) {
35484
+ const requestBody = {
35485
+ api_token: apiKey,
35486
+ project_name: projectName
35487
+ };
35488
+ if (instanceId) {
35489
+ requestBody.instance_id = instanceId;
35490
+ }
35491
+ for (let attempt = 0;attempt <= retries; attempt++) {
35492
+ if (attempt > 0 && retryDelayMs > 0) {
35493
+ await new Promise((resolve8) => setTimeout(resolve8, retryDelayMs));
35494
+ }
35495
+ try {
35496
+ const res = await fetch(url, {
35497
+ method: "POST",
35498
+ headers: {
35499
+ "Content-Type": "application/json"
35500
+ },
35501
+ body: JSON.stringify(requestBody)
35502
+ });
35503
+ const body = await res.text().catch(() => "");
35504
+ if (!res.ok) {
35505
+ if (debug) {
35506
+ console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
35507
+ console.error(`Debug: Response: ${body}`);
35508
+ }
35509
+ continue;
35510
+ }
35258
35511
  if (debug) {
35259
- console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
35260
- console.error(`Debug: Response: ${body}`);
35512
+ console.error(`Debug: Monitoring registration response: ${body}`);
35513
+ }
35514
+ try {
35515
+ const parsed = JSON.parse(body);
35516
+ return {
35517
+ instanceId: typeof parsed.instance_id === "string" ? parsed.instance_id : undefined,
35518
+ projectId: typeof parsed.project_id === "number" ? parsed.project_id : undefined,
35519
+ projectName: typeof parsed.project_name === "string" ? parsed.project_name : undefined,
35520
+ created: typeof parsed.created === "boolean" ? parsed.created : undefined
35521
+ };
35522
+ } catch {
35523
+ return {};
35524
+ }
35525
+ } catch (err) {
35526
+ if (debug) {
35527
+ console.error(`Debug: Monitoring registration error: ${err.message}`);
35261
35528
  }
35262
- return;
35263
- }
35264
- if (debug) {
35265
- console.error(`Debug: Monitoring registration response: ${body}`);
35266
- }
35267
- }).catch((err) => {
35268
- if (debug) {
35269
- console.error(`Debug: Monitoring registration error: ${err.message}`);
35270
35529
  }
35271
- });
35530
+ }
35531
+ return null;
35532
+ }
35533
+ var PROJECT_NAME_RE = /^[A-Za-z0-9._-]+$/;
35534
+ function resolveAdoptedProject(reg) {
35535
+ if (!reg)
35536
+ return null;
35537
+ if (typeof reg.projectId === "number" && Number.isFinite(reg.projectId)) {
35538
+ return String(reg.projectId);
35539
+ }
35540
+ if (typeof reg.projectName === "string" && PROJECT_NAME_RE.test(reg.projectName)) {
35541
+ return reg.projectName;
35542
+ }
35543
+ return null;
35272
35544
  }
35273
35545
  function updatePgwatchConfig(configPath, updates) {
35274
35546
  let lines = [];
@@ -35389,7 +35661,7 @@ mon.command("local-install").description("install local monitoring stack (genera
35389
35661
  " PGAI_ENABLE_IPV6=false (accepted: true|false|yes|no, lowercase)",
35390
35662
  ""
35391
35663
  ].join(`
35392
- `)).option("--demo", "demo mode with sample database", false).option("--api-key <key>", "Postgres AI API key for automated report uploads").option("--db-url <url>", "PostgreSQL connection URL to monitor").option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)").option("--project <name>", "Docker Compose project name (default: postgres_ai)").option("-y, --yes", "accept all defaults and skip interactive prompts", false).action(async (opts) => {
35664
+ `)).option("--demo", "demo mode with sample database", false).option("--api-key <key>", "Postgres AI API key for automated report uploads").option("--db-url <url>", "PostgreSQL connection URL to monitor").option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)").option("--project <name>", "Docker Compose project name (default: postgres_ai)").option("--instance-id <uuid>", "adopt a console-provisioned monitoring instance instead of self-registering a new one (set automatically by the provisioning flow; PGAI_INSTANCE_ID env also works)").option("-y, --yes", "accept all defaults and skip interactive prompts", false).action(async (opts) => {
35393
35665
  const globalOpts = program2.opts();
35394
35666
  let apiKey = opts.apiKey || globalOpts.apiKey;
35395
35667
  console.log(`
@@ -35756,10 +36028,32 @@ Searched: ${demoCandidates.join(", ")}
35756
36028
  `);
35757
36029
  if (apiKey && !opts.demo) {
35758
36030
  const projectName = opts.project || "postgres-ai-monitoring";
35759
- registerMonitoringInstance(apiKey, projectName, {
35760
- apiBaseUrl: globalOpts.apiBaseUrl,
35761
- debug: !!process.env.DEBUG
35762
- });
36031
+ const instanceId = opts.instanceId || process.env.PGAI_INSTANCE_ID;
36032
+ if (instanceId) {
36033
+ const reg = await registerMonitoringInstance(apiKey, projectName, {
36034
+ apiBaseUrl: globalOpts.apiBaseUrl,
36035
+ debug: !!process.env.DEBUG,
36036
+ instanceId
36037
+ });
36038
+ const adoptedProject = resolveAdoptedProject(reg);
36039
+ if (adoptedProject != null) {
36040
+ updatePgwatchConfig(path7.resolve(projectDir, ".pgwatch-config"), {
36041
+ project_name: adoptedProject
36042
+ });
36043
+ const verb = reg?.created ? "Registered" : "Adopted";
36044
+ console.log(`\u2713 ${verb} monitoring instance (project: ${adoptedProject})
36045
+ `);
36046
+ } else if (reg) {
36047
+ console.error(`\u26A0 Adopted provisioned instance ${instanceId} but the platform returned no project \u2014 reports will use project '${projectName}'`);
36048
+ } else {
36049
+ console.error(`\u26A0 Could not adopt provisioned instance ${instanceId} \u2014 reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`);
36050
+ }
36051
+ } else {
36052
+ registerMonitoringInstance(apiKey, projectName, {
36053
+ apiBaseUrl: globalOpts.apiBaseUrl,
36054
+ debug: !!process.env.DEBUG
36055
+ });
36056
+ }
35763
36057
  }
35764
36058
  console.log("=================================");
35765
36059
  console.log(" Local install completed!");
@@ -37515,6 +37809,8 @@ if (__require.main == __require.module) {
37515
37809
  });
37516
37810
  }
37517
37811
  export {
37812
+ resolveAdoptedProject,
37813
+ registerMonitoringInstance,
37518
37814
  refreshBundledComposeIfStale,
37519
37815
  readDeployedTag,
37520
37816
  isValidComposeYaml