postgresai 0.16.0-dev.1 → 0.16.0-dev.11

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.
@@ -2,126 +2,6 @@
2
2
  -- These functions use SECURITY DEFINER to allow the monitoring user to perform
3
3
  -- operations they don't have direct permissions for.
4
4
 
5
- /*
6
- * explain_generic
7
- *
8
- * Function to get generic explain plans with optional HypoPG index testing.
9
- * Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
10
- *
11
- * Security notes:
12
- * - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
13
- * - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
14
- * - Input validation uses a simple heuristic to detect multiple statements
15
- * (Note: may reject valid queries containing semicolons in string literals)
16
- *
17
- * Usage examples:
18
- * -- Basic generic plan
19
- * select postgres_ai.explain_generic('select * from users where id = $1');
20
- *
21
- * -- JSON format
22
- * select postgres_ai.explain_generic('select * from users where id = $1', 'json');
23
- *
24
- * -- Test a hypothetical index
25
- * select postgres_ai.explain_generic(
26
- * 'select * from users where email = $1',
27
- * 'text',
28
- * 'create index on users (email)'
29
- * );
30
- */
31
- create or replace function postgres_ai.explain_generic(
32
- in query text,
33
- in format text default 'text',
34
- in hypopg_index text default null,
35
- out result text
36
- )
37
- language plpgsql
38
- security definer
39
- set search_path = pg_catalog, public
40
- as $$
41
- declare
42
- v_line record;
43
- v_lines text[] := '{}';
44
- v_explain_query text;
45
- v_hypo_result record;
46
- v_version int;
47
- v_hypopg_available boolean;
48
- v_clean_query text;
49
- begin
50
- -- Check PostgreSQL version (generic_plan requires 16+)
51
- select current_setting('server_version_num')::int into v_version;
52
-
53
- if v_version < 160000 then
54
- raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
55
- current_setting('server_version');
56
- end if;
57
-
58
- -- Input validation: reject empty queries
59
- if query is null or trim(query) = '' then
60
- raise exception 'query cannot be empty';
61
- end if;
62
-
63
- -- Input validation: detect multiple statements (defense-in-depth)
64
- -- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
65
- -- Limitation: Queries with semicolons inside string literals will be rejected
66
- v_clean_query := trim(query);
67
- if v_clean_query like '%;%' then
68
- -- Strip trailing semicolon if present (common user convenience)
69
- v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
70
- -- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
71
- if v_clean_query like '%;%' then
72
- raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
73
- end if;
74
- end if;
75
-
76
- -- Check if HypoPG extension is available
77
- if hypopg_index is not null then
78
- select exists(
79
- select 1 from pg_extension where extname = 'hypopg'
80
- ) into v_hypopg_available;
81
-
82
- if not v_hypopg_available then
83
- raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
84
- end if;
85
-
86
- -- Create hypothetical index
87
- select * into v_hypo_result from hypopg_create_index(hypopg_index);
88
- raise notice 'Created hypothetical index: % (oid: %)',
89
- v_hypo_result.indexname, v_hypo_result.indexrelid;
90
- end if;
91
-
92
- -- Build and execute EXPLAIN query
93
- -- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
94
- begin
95
- if lower(format) = 'json' then
96
- execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
97
- into result;
98
- else
99
- for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
100
- v_lines := array_append(v_lines, v_line."QUERY PLAN");
101
- end loop;
102
- result := array_to_string(v_lines, e'\n');
103
- end if;
104
- exception when others then
105
- -- Clean up hypothetical index before re-raising
106
- if hypopg_index is not null then
107
- perform hypopg_reset();
108
- end if;
109
- raise;
110
- end;
111
-
112
- -- Clean up hypothetical index
113
- if hypopg_index is not null then
114
- perform hypopg_reset();
115
- end if;
116
- end;
117
- $$;
118
-
119
- comment on function postgres_ai.explain_generic(text, text, text) is
120
- 'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
121
-
122
- -- Grant execute to the monitoring user
123
- grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
124
-
125
5
  /*
126
6
  * table_describe
127
7
  *
@@ -435,5 +315,3 @@ comment on function postgres_ai.table_describe(text) is
435
315
  'Returns comprehensive table information in compact text format for LLM analysis';
436
316
 
437
317
  grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
438
-
439
-
@@ -2,126 +2,6 @@
2
2
  -- These functions use SECURITY DEFINER to allow the monitoring user to perform
3
3
  -- operations they don't have direct permissions for.
4
4
 
5
- /*
6
- * explain_generic
7
- *
8
- * Function to get generic explain plans with optional HypoPG index testing.
9
- * Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
10
- *
11
- * Security notes:
12
- * - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
13
- * - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
14
- * - Input validation uses a simple heuristic to detect multiple statements
15
- * (Note: may reject valid queries containing semicolons in string literals)
16
- *
17
- * Usage examples:
18
- * -- Basic generic plan
19
- * select postgres_ai.explain_generic('select * from users where id = $1');
20
- *
21
- * -- JSON format
22
- * select postgres_ai.explain_generic('select * from users where id = $1', 'json');
23
- *
24
- * -- Test a hypothetical index
25
- * select postgres_ai.explain_generic(
26
- * 'select * from users where email = $1',
27
- * 'text',
28
- * 'create index on users (email)'
29
- * );
30
- */
31
- create or replace function postgres_ai.explain_generic(
32
- in query text,
33
- in format text default 'text',
34
- in hypopg_index text default null,
35
- out result text
36
- )
37
- language plpgsql
38
- security definer
39
- set search_path = pg_catalog, public
40
- as $$
41
- declare
42
- v_line record;
43
- v_lines text[] := '{}';
44
- v_explain_query text;
45
- v_hypo_result record;
46
- v_version int;
47
- v_hypopg_available boolean;
48
- v_clean_query text;
49
- begin
50
- -- Check PostgreSQL version (generic_plan requires 16+)
51
- select current_setting('server_version_num')::int into v_version;
52
-
53
- if v_version < 160000 then
54
- raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
55
- current_setting('server_version');
56
- end if;
57
-
58
- -- Input validation: reject empty queries
59
- if query is null or trim(query) = '' then
60
- raise exception 'query cannot be empty';
61
- end if;
62
-
63
- -- Input validation: detect multiple statements (defense-in-depth)
64
- -- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
65
- -- Limitation: Queries with semicolons inside string literals will be rejected
66
- v_clean_query := trim(query);
67
- if v_clean_query like '%;%' then
68
- -- Strip trailing semicolon if present (common user convenience)
69
- v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
70
- -- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
71
- if v_clean_query like '%;%' then
72
- raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
73
- end if;
74
- end if;
75
-
76
- -- Check if HypoPG extension is available
77
- if hypopg_index is not null then
78
- select exists(
79
- select 1 from pg_extension where extname = 'hypopg'
80
- ) into v_hypopg_available;
81
-
82
- if not v_hypopg_available then
83
- raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
84
- end if;
85
-
86
- -- Create hypothetical index
87
- select * into v_hypo_result from hypopg_create_index(hypopg_index);
88
- raise notice 'Created hypothetical index: % (oid: %)',
89
- v_hypo_result.indexname, v_hypo_result.indexrelid;
90
- end if;
91
-
92
- -- Build and execute EXPLAIN query
93
- -- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
94
- begin
95
- if lower(format) = 'json' then
96
- execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
97
- into result;
98
- else
99
- for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
100
- v_lines := array_append(v_lines, v_line."QUERY PLAN");
101
- end loop;
102
- result := array_to_string(v_lines, e'\n');
103
- end if;
104
- exception when others then
105
- -- Clean up hypothetical index before re-raising
106
- if hypopg_index is not null then
107
- perform hypopg_reset();
108
- end if;
109
- raise;
110
- end;
111
-
112
- -- Clean up hypothetical index
113
- if hypopg_index is not null then
114
- perform hypopg_reset();
115
- end if;
116
- end;
117
- $$;
118
-
119
- comment on function postgres_ai.explain_generic(text, text, text) is
120
- 'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
121
-
122
- -- Grant execute to the monitoring user
123
- grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
124
-
125
5
  /*
126
6
  * table_describe
127
7
  *
@@ -435,5 +315,3 @@ comment on function postgres_ai.table_describe(text) is
435
315
  'Returns comprehensive table information in compact text format for LLM analysis';
436
316
 
437
317
  grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
438
-
439
-
@@ -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
@@ -924,14 +924,6 @@ export async function verifyInitSetup(params: {
924
924
  }
925
925
 
926
926
  // Check for helper functions
927
- const explainFnRes = await params.client.query(
928
- "select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok",
929
- [role]
930
- );
931
- if (!explainFnRes.rows?.[0]?.ok) {
932
- missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
933
- }
934
-
935
927
  const tableDescribeFnRes = await params.client.query(
936
928
  "select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok",
937
929
  [role]
@@ -1027,10 +1019,28 @@ export async function checkCurrentUserPermissions(
1027
1019
 
1028
1020
  union all
1029
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
+
1030
1039
  select
1031
1040
  'postgres_ai.pg_statistic view exists' as permission_name,
1032
1041
  'optional' as status,
1033
1042
  case
1043
+ when to_regnamespace('postgres_ai') is null then null
1034
1044
  when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
1035
1045
  else to_regclass('postgres_ai.pg_statistic') is not null
1036
1046
  end as granted
@@ -1041,6 +1051,7 @@ export async function checkCurrentUserPermissions(
1041
1051
  'select on postgres_ai.pg_statistic' as permission_name,
1042
1052
  'optional' as status,
1043
1053
  case
1054
+ when to_regnamespace('postgres_ai') is null then null
1044
1055
  when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
1045
1056
  when to_regclass('postgres_ai.pg_statistic') is null then null
1046
1057
  else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
@@ -1060,6 +1071,10 @@ export async function checkCurrentUserPermissions(
1060
1071
  when permission_name like 'select on pg_catalog.pg_index' then
1061
1072
  format('grant select on pg_catalog.pg_index to %I;', current_user)
1062
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)
1063
1078
  when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
1064
1079
  '-- create postgres_ai.pg_statistic view (see setup script)'
1065
1080
  when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
@@ -1105,6 +1120,12 @@ export function formatPermissionCheckMessages(result: PreflightPermissionResult)
1105
1120
  const errors: string[] = [];
1106
1121
 
1107
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
+ }
1108
1129
  const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
1109
1130
  warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
1110
1131
  }