postgresai 0.14.0-dev.55 → 0.14.0-dev.56
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/dist/bin/postgres-ai.js +11 -8
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.permissions.sql +37 -0
- package/dist/sql/03.optional_rds.sql +6 -0
- package/dist/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/05.helpers.sql +415 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.permissions.sql +37 -0
- package/dist/sql/sql/03.optional_rds.sql +6 -0
- package/dist/sql/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/sql/05.helpers.sql +415 -0
- package/lib/checkup.ts +3 -0
- package/lib/init.ts +9 -3
- package/lib/metrics-embedded.ts +2 -2
- package/package.json +2 -2
- package/test/checkup.integration.test.ts +46 -0
- package/test/checkup.test.ts +3 -2
- package/test/schema-validation.test.ts +1 -1
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -13064,7 +13064,7 @@ var {
|
|
|
13064
13064
|
// package.json
|
|
13065
13065
|
var package_default = {
|
|
13066
13066
|
name: "postgresai",
|
|
13067
|
-
version: "0.14.0-dev.
|
|
13067
|
+
version: "0.14.0-dev.56",
|
|
13068
13068
|
description: "postgres_ai CLI",
|
|
13069
13069
|
license: "Apache-2.0",
|
|
13070
13070
|
private: false,
|
|
@@ -13090,7 +13090,7 @@ var package_default = {
|
|
|
13090
13090
|
},
|
|
13091
13091
|
scripts: {
|
|
13092
13092
|
"embed-metrics": "bun run scripts/embed-metrics.ts",
|
|
13093
|
-
build: `bun run embed-metrics && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e "const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))"`,
|
|
13093
|
+
build: `bun run embed-metrics && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e "const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))" && cp -r ./sql ./dist/sql`,
|
|
13094
13094
|
prepublishOnly: "npm run build",
|
|
13095
13095
|
start: "bun ./bin/postgres-ai.ts --help",
|
|
13096
13096
|
"start:node": "node ./dist/bin/postgres-ai.js --help",
|
|
@@ -15887,7 +15887,7 @@ var Result = import_lib.default.Result;
|
|
|
15887
15887
|
var TypeOverrides = import_lib.default.TypeOverrides;
|
|
15888
15888
|
var defaults = import_lib.default.defaults;
|
|
15889
15889
|
// package.json
|
|
15890
|
-
var version = "0.14.0-dev.
|
|
15890
|
+
var version = "0.14.0-dev.56";
|
|
15891
15891
|
var package_default2 = {
|
|
15892
15892
|
name: "postgresai",
|
|
15893
15893
|
version,
|
|
@@ -15916,7 +15916,7 @@ var package_default2 = {
|
|
|
15916
15916
|
},
|
|
15917
15917
|
scripts: {
|
|
15918
15918
|
"embed-metrics": "bun run scripts/embed-metrics.ts",
|
|
15919
|
-
build: `bun run embed-metrics && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e "const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))"`,
|
|
15919
|
+
build: `bun run embed-metrics && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e "const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))" && cp -r ./sql ./dist/sql`,
|
|
15920
15920
|
prepublishOnly: "npm run build",
|
|
15921
15921
|
start: "bun ./bin/postgres-ai.ts --help",
|
|
15922
15922
|
"start:node": "node ./dist/bin/postgres-ai.js --help",
|
|
@@ -23474,10 +23474,9 @@ function resolveBaseUrls2(opts, cfg, defaults2 = {}) {
|
|
|
23474
23474
|
|
|
23475
23475
|
// lib/init.ts
|
|
23476
23476
|
import { randomBytes } from "crypto";
|
|
23477
|
-
import { URL as URL2 } from "url";
|
|
23477
|
+
import { URL as URL2, fileURLToPath } from "url";
|
|
23478
23478
|
import * as fs3 from "fs";
|
|
23479
23479
|
import * as path3 from "path";
|
|
23480
|
-
var __dirname = "/builds/postgres-ai/postgres_ai/cli/lib";
|
|
23481
23480
|
var DEFAULT_MONITORING_USER = "postgres_ai_mon";
|
|
23482
23481
|
function sslModeToConfig(mode) {
|
|
23483
23482
|
if (mode.toLowerCase() === "disable")
|
|
@@ -23558,9 +23557,11 @@ async function connectWithSslFallback(ClientClass, adminConn, verbose) {
|
|
|
23558
23557
|
}
|
|
23559
23558
|
}
|
|
23560
23559
|
function sqlDir() {
|
|
23560
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
23561
|
+
const currentDir = path3.dirname(currentFile);
|
|
23561
23562
|
const candidates = [
|
|
23562
|
-
path3.resolve(
|
|
23563
|
-
path3.resolve(
|
|
23563
|
+
path3.resolve(currentDir, "..", "sql"),
|
|
23564
|
+
path3.resolve(currentDir, "..", "..", "sql")
|
|
23564
23565
|
];
|
|
23565
23566
|
for (const candidate of candidates) {
|
|
23566
23567
|
if (fs3.existsSync(candidate)) {
|
|
@@ -24517,6 +24518,7 @@ where
|
|
|
24517
24518
|
quote_ident(pci.relname) as tag_index_name,
|
|
24518
24519
|
quote_ident(pct.relname) as tag_table_name,
|
|
24519
24520
|
coalesce(nullif(quote_ident(pn.nspname), 'public') || '.', '') || quote_ident(pct.relname) as tag_relation_name,
|
|
24521
|
+
pg_get_indexdef(pidx.indexrelid) as index_definition,
|
|
24520
24522
|
pg_relation_size(pidx.indexrelid) index_size_bytes,
|
|
24521
24523
|
((
|
|
24522
24524
|
select count(1)
|
|
@@ -25154,6 +25156,7 @@ async function getInvalidIndexes(client, pgMajorVersion = 16) {
|
|
|
25154
25156
|
relation_name: String(transformed.relation_name || ""),
|
|
25155
25157
|
index_size_bytes: indexSizeBytes,
|
|
25156
25158
|
index_size_pretty: formatBytes(indexSizeBytes),
|
|
25159
|
+
index_definition: String(transformed.index_definition || ""),
|
|
25157
25160
|
supports_fk: toBool(transformed.supports_fk)
|
|
25158
25161
|
};
|
|
25159
25162
|
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Role creation / password update (template-filled by cli/lib/init.ts)
|
|
2
|
+
--
|
|
3
|
+
-- Always uses a race-safe pattern (create if missing, then always alter to set the password):
|
|
4
|
+
-- do $$ begin
|
|
5
|
+
-- if not exists (select 1 from pg_catalog.pg_roles where rolname = '...') then
|
|
6
|
+
-- begin
|
|
7
|
+
-- create user "..." with password '...';
|
|
8
|
+
-- exception when duplicate_object then
|
|
9
|
+
-- null;
|
|
10
|
+
-- end;
|
|
11
|
+
-- end if;
|
|
12
|
+
-- alter user "..." with password '...';
|
|
13
|
+
-- end $$;
|
|
14
|
+
{{ROLE_STMT}}
|
|
15
|
+
|
|
16
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Required permissions for postgres_ai monitoring user (template-filled by cli/lib/init.ts)
|
|
2
|
+
|
|
3
|
+
-- Allow connect
|
|
4
|
+
grant connect on database {{DB_IDENT}} to {{ROLE_IDENT}};
|
|
5
|
+
|
|
6
|
+
-- Standard monitoring privileges
|
|
7
|
+
grant pg_monitor to {{ROLE_IDENT}};
|
|
8
|
+
grant select on pg_catalog.pg_index to {{ROLE_IDENT}};
|
|
9
|
+
|
|
10
|
+
-- Create postgres_ai schema for our objects
|
|
11
|
+
create schema if not exists postgres_ai;
|
|
12
|
+
grant usage on schema postgres_ai to {{ROLE_IDENT}};
|
|
13
|
+
|
|
14
|
+
-- For bloat analysis: expose pg_statistic via a view
|
|
15
|
+
create or replace view postgres_ai.pg_statistic as
|
|
16
|
+
select
|
|
17
|
+
n.nspname as schemaname,
|
|
18
|
+
c.relname as tablename,
|
|
19
|
+
a.attname,
|
|
20
|
+
s.stanullfrac as null_frac,
|
|
21
|
+
s.stawidth as avg_width,
|
|
22
|
+
false as inherited
|
|
23
|
+
from pg_catalog.pg_statistic s
|
|
24
|
+
join pg_catalog.pg_class c on c.oid = s.starelid
|
|
25
|
+
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
26
|
+
join pg_catalog.pg_attribute a on a.attrelid = s.starelid and a.attnum = s.staattnum
|
|
27
|
+
where a.attnum > 0 and not a.attisdropped;
|
|
28
|
+
|
|
29
|
+
grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
|
|
30
|
+
|
|
31
|
+
-- Hardened clusters sometimes revoke PUBLIC on schema public
|
|
32
|
+
grant usage on schema public to {{ROLE_IDENT}};
|
|
33
|
+
|
|
34
|
+
-- Keep search_path predictable; postgres_ai first so our objects are found
|
|
35
|
+
alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
|
|
36
|
+
|
|
37
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Optional permissions for self-managed Postgres (best effort)
|
|
2
|
+
|
|
3
|
+
grant execute on function pg_catalog.pg_stat_file(text) to {{ROLE_IDENT}};
|
|
4
|
+
grant execute on function pg_catalog.pg_stat_file(text, boolean) to {{ROLE_IDENT}};
|
|
5
|
+
grant execute on function pg_catalog.pg_ls_dir(text) to {{ROLE_IDENT}};
|
|
6
|
+
grant execute on function pg_catalog.pg_ls_dir(text, boolean, boolean) to {{ROLE_IDENT}};
|
|
7
|
+
|
|
8
|
+
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
-- Helper functions for postgres_ai monitoring user (template-filled by cli/lib/init.ts)
|
|
2
|
+
-- These functions use SECURITY DEFINER to allow the monitoring user to perform
|
|
3
|
+
-- operations they don't have direct permissions for.
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* pgai_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
|
+
* Usage examples:
|
|
12
|
+
* -- Basic generic plan
|
|
13
|
+
* select postgres_ai.explain_generic('select * from users where id = $1');
|
|
14
|
+
*
|
|
15
|
+
* -- JSON format
|
|
16
|
+
* select postgres_ai.explain_generic('select * from users where id = $1', 'json');
|
|
17
|
+
*
|
|
18
|
+
* -- Test a hypothetical index
|
|
19
|
+
* select postgres_ai.explain_generic(
|
|
20
|
+
* 'select * from users where email = $1',
|
|
21
|
+
* 'text',
|
|
22
|
+
* 'create index on users (email)'
|
|
23
|
+
* );
|
|
24
|
+
*/
|
|
25
|
+
create or replace function postgres_ai.explain_generic(
|
|
26
|
+
in query text,
|
|
27
|
+
in format text default 'text',
|
|
28
|
+
in hypopg_index text default null,
|
|
29
|
+
out result text
|
|
30
|
+
)
|
|
31
|
+
language plpgsql
|
|
32
|
+
security definer
|
|
33
|
+
set search_path = pg_catalog, public
|
|
34
|
+
as $$
|
|
35
|
+
declare
|
|
36
|
+
v_line record;
|
|
37
|
+
v_lines text[] := '{}';
|
|
38
|
+
v_explain_query text;
|
|
39
|
+
v_hypo_result record;
|
|
40
|
+
v_version int;
|
|
41
|
+
v_hypopg_available boolean;
|
|
42
|
+
begin
|
|
43
|
+
-- Check PostgreSQL version (generic_plan requires 16+)
|
|
44
|
+
select current_setting('server_version_num')::int into v_version;
|
|
45
|
+
|
|
46
|
+
if v_version < 160000 then
|
|
47
|
+
raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
|
|
48
|
+
current_setting('server_version');
|
|
49
|
+
end if;
|
|
50
|
+
|
|
51
|
+
-- Check if HypoPG extension is available
|
|
52
|
+
if hypopg_index is not null then
|
|
53
|
+
select exists(
|
|
54
|
+
select 1 from pg_extension where extname = 'hypopg'
|
|
55
|
+
) into v_hypopg_available;
|
|
56
|
+
|
|
57
|
+
if not v_hypopg_available then
|
|
58
|
+
raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
|
|
59
|
+
end if;
|
|
60
|
+
|
|
61
|
+
-- Create hypothetical index
|
|
62
|
+
select * into v_hypo_result from hypopg_create_index(hypopg_index);
|
|
63
|
+
raise notice 'Created hypothetical index: % (oid: %)',
|
|
64
|
+
v_hypo_result.indexname, v_hypo_result.indexrelid;
|
|
65
|
+
end if;
|
|
66
|
+
|
|
67
|
+
-- Build and execute explain query based on format
|
|
68
|
+
-- Output is preserved exactly as EXPLAIN returns it
|
|
69
|
+
begin
|
|
70
|
+
if lower(format) = 'json' then
|
|
71
|
+
v_explain_query := 'explain (verbose, settings, generic_plan, format json) ' || query;
|
|
72
|
+
execute v_explain_query into result;
|
|
73
|
+
else
|
|
74
|
+
v_explain_query := 'explain (verbose, settings, generic_plan) ' || query;
|
|
75
|
+
for v_line in execute v_explain_query loop
|
|
76
|
+
v_lines := array_append(v_lines, v_line."QUERY PLAN");
|
|
77
|
+
end loop;
|
|
78
|
+
result := array_to_string(v_lines, e'\n');
|
|
79
|
+
end if;
|
|
80
|
+
exception when others then
|
|
81
|
+
-- Clean up hypothetical index before re-raising
|
|
82
|
+
if hypopg_index is not null then
|
|
83
|
+
perform hypopg_reset();
|
|
84
|
+
end if;
|
|
85
|
+
raise;
|
|
86
|
+
end;
|
|
87
|
+
|
|
88
|
+
-- Clean up hypothetical index
|
|
89
|
+
if hypopg_index is not null then
|
|
90
|
+
perform hypopg_reset();
|
|
91
|
+
end if;
|
|
92
|
+
end;
|
|
93
|
+
$$;
|
|
94
|
+
|
|
95
|
+
comment on function postgres_ai.explain_generic(text, text, text) is
|
|
96
|
+
'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
|
|
97
|
+
|
|
98
|
+
-- Grant execute to the monitoring user
|
|
99
|
+
grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
|
|
100
|
+
|
|
101
|
+
/*
|
|
102
|
+
* table_describe
|
|
103
|
+
*
|
|
104
|
+
* Collects comprehensive information about a table for LLM analysis.
|
|
105
|
+
* Returns a compact text format with:
|
|
106
|
+
* - Table metadata (type, size estimates)
|
|
107
|
+
* - Columns (name, type, nullable, default)
|
|
108
|
+
* - Indexes
|
|
109
|
+
* - Constraints (PK, FK, unique, check)
|
|
110
|
+
* - Maintenance stats (vacuum/analyze times)
|
|
111
|
+
*
|
|
112
|
+
* Usage:
|
|
113
|
+
* select postgres_ai.table_describe('public.users');
|
|
114
|
+
* select postgres_ai.table_describe('my_table'); -- uses search_path
|
|
115
|
+
*/
|
|
116
|
+
create or replace function postgres_ai.table_describe(
|
|
117
|
+
in table_name text,
|
|
118
|
+
out result text
|
|
119
|
+
)
|
|
120
|
+
language plpgsql
|
|
121
|
+
security definer
|
|
122
|
+
set search_path = pg_catalog, public
|
|
123
|
+
as $$
|
|
124
|
+
declare
|
|
125
|
+
v_oid oid;
|
|
126
|
+
v_schema text;
|
|
127
|
+
v_table text;
|
|
128
|
+
v_relkind char;
|
|
129
|
+
v_relpages int;
|
|
130
|
+
v_reltuples float;
|
|
131
|
+
v_lines text[] := '{}';
|
|
132
|
+
v_line text;
|
|
133
|
+
v_rec record;
|
|
134
|
+
v_constraint_count int := 0;
|
|
135
|
+
begin
|
|
136
|
+
-- Resolve table name to OID (handles schema-qualified and search_path)
|
|
137
|
+
v_oid := table_name::regclass::oid;
|
|
138
|
+
|
|
139
|
+
-- Get basic table info
|
|
140
|
+
select
|
|
141
|
+
n.nspname,
|
|
142
|
+
c.relname,
|
|
143
|
+
c.relkind,
|
|
144
|
+
c.relpages,
|
|
145
|
+
c.reltuples
|
|
146
|
+
into v_schema, v_table, v_relkind, v_relpages, v_reltuples
|
|
147
|
+
from pg_class c
|
|
148
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
149
|
+
where c.oid = v_oid;
|
|
150
|
+
|
|
151
|
+
-- Validate object type - only tables, views, and materialized views are supported
|
|
152
|
+
if v_relkind not in ('r', 'p', 'v', 'm', 'f') then
|
|
153
|
+
raise exception 'table_describe does not support % (relkind=%)',
|
|
154
|
+
case v_relkind
|
|
155
|
+
when 'i' then 'indexes'
|
|
156
|
+
when 'I' then 'partitioned indexes'
|
|
157
|
+
when 'S' then 'sequences'
|
|
158
|
+
when 't' then 'TOAST tables'
|
|
159
|
+
when 'c' then 'composite types'
|
|
160
|
+
else format('objects of type "%s"', v_relkind)
|
|
161
|
+
end,
|
|
162
|
+
v_relkind;
|
|
163
|
+
end if;
|
|
164
|
+
|
|
165
|
+
-- Header
|
|
166
|
+
v_lines := array_append(v_lines, format('Table: %I.%I', v_schema, v_table));
|
|
167
|
+
v_lines := array_append(v_lines, format('Type: %s | relpages: %s | reltuples: %s',
|
|
168
|
+
case v_relkind
|
|
169
|
+
when 'r' then 'table'
|
|
170
|
+
when 'p' then 'partitioned table'
|
|
171
|
+
when 'v' then 'view'
|
|
172
|
+
when 'm' then 'materialized view'
|
|
173
|
+
when 'f' then 'foreign table'
|
|
174
|
+
end,
|
|
175
|
+
v_relpages,
|
|
176
|
+
case when v_reltuples < 0 then '-1' else v_reltuples::bigint::text end
|
|
177
|
+
));
|
|
178
|
+
|
|
179
|
+
-- Vacuum/analyze stats (only for tables and materialized views, not views)
|
|
180
|
+
if v_relkind in ('r', 'p', 'm', 'f') then
|
|
181
|
+
select
|
|
182
|
+
format('Vacuum: %s (auto: %s) | Analyze: %s (auto: %s)',
|
|
183
|
+
coalesce(to_char(last_vacuum at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
|
|
184
|
+
coalesce(to_char(last_autovacuum at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
|
|
185
|
+
coalesce(to_char(last_analyze at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
|
|
186
|
+
coalesce(to_char(last_autoanalyze at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never')
|
|
187
|
+
)
|
|
188
|
+
into v_line
|
|
189
|
+
from pg_stat_all_tables
|
|
190
|
+
where relid = v_oid;
|
|
191
|
+
|
|
192
|
+
if v_line is not null then
|
|
193
|
+
v_lines := array_append(v_lines, v_line);
|
|
194
|
+
end if;
|
|
195
|
+
end if;
|
|
196
|
+
|
|
197
|
+
v_lines := array_append(v_lines, '');
|
|
198
|
+
|
|
199
|
+
-- Columns
|
|
200
|
+
v_lines := array_append(v_lines, 'Columns:');
|
|
201
|
+
for v_rec in
|
|
202
|
+
select
|
|
203
|
+
a.attname,
|
|
204
|
+
format_type(a.atttypid, a.atttypmod) as data_type,
|
|
205
|
+
a.attnotnull,
|
|
206
|
+
(select pg_get_expr(d.adbin, d.adrelid, true)
|
|
207
|
+
from pg_attrdef d
|
|
208
|
+
where d.adrelid = a.attrelid and d.adnum = a.attnum and a.atthasdef) as default_val,
|
|
209
|
+
a.attidentity,
|
|
210
|
+
a.attgenerated
|
|
211
|
+
from pg_attribute a
|
|
212
|
+
where a.attrelid = v_oid
|
|
213
|
+
and a.attnum > 0
|
|
214
|
+
and not a.attisdropped
|
|
215
|
+
order by a.attnum
|
|
216
|
+
loop
|
|
217
|
+
v_line := format(' %s %s', v_rec.attname, v_rec.data_type);
|
|
218
|
+
|
|
219
|
+
if v_rec.attnotnull then
|
|
220
|
+
v_line := v_line || ' NOT NULL';
|
|
221
|
+
end if;
|
|
222
|
+
|
|
223
|
+
if v_rec.attidentity = 'a' then
|
|
224
|
+
v_line := v_line || ' GENERATED ALWAYS AS IDENTITY';
|
|
225
|
+
elsif v_rec.attidentity = 'd' then
|
|
226
|
+
v_line := v_line || ' GENERATED BY DEFAULT AS IDENTITY';
|
|
227
|
+
elsif v_rec.attgenerated = 's' then
|
|
228
|
+
v_line := v_line || format(' GENERATED ALWAYS AS (%s) STORED', v_rec.default_val);
|
|
229
|
+
elsif v_rec.default_val is not null then
|
|
230
|
+
v_line := v_line || format(' DEFAULT %s', v_rec.default_val);
|
|
231
|
+
end if;
|
|
232
|
+
|
|
233
|
+
v_lines := array_append(v_lines, v_line);
|
|
234
|
+
end loop;
|
|
235
|
+
|
|
236
|
+
-- View definition (for views and materialized views)
|
|
237
|
+
if v_relkind in ('v', 'm') then
|
|
238
|
+
v_lines := array_append(v_lines, '');
|
|
239
|
+
v_lines := array_append(v_lines, 'Definition:');
|
|
240
|
+
v_line := pg_get_viewdef(v_oid, true);
|
|
241
|
+
if v_line is not null then
|
|
242
|
+
-- Indent the view definition
|
|
243
|
+
v_line := ' ' || replace(v_line, e'\n', e'\n ');
|
|
244
|
+
v_lines := array_append(v_lines, v_line);
|
|
245
|
+
end if;
|
|
246
|
+
end if;
|
|
247
|
+
|
|
248
|
+
-- Indexes (tables, partitioned tables, and materialized views can have indexes)
|
|
249
|
+
if v_relkind in ('r', 'p', 'm') then
|
|
250
|
+
v_lines := array_append(v_lines, '');
|
|
251
|
+
v_lines := array_append(v_lines, 'Indexes:');
|
|
252
|
+
for v_rec in
|
|
253
|
+
select
|
|
254
|
+
i.relname as index_name,
|
|
255
|
+
pg_get_indexdef(i.oid) as index_def,
|
|
256
|
+
ix.indisprimary,
|
|
257
|
+
ix.indisunique
|
|
258
|
+
from pg_index ix
|
|
259
|
+
join pg_class i on i.oid = ix.indexrelid
|
|
260
|
+
where ix.indrelid = v_oid
|
|
261
|
+
order by ix.indisprimary desc, ix.indisunique desc, i.relname
|
|
262
|
+
loop
|
|
263
|
+
v_line := ' ';
|
|
264
|
+
if v_rec.indisprimary then
|
|
265
|
+
v_line := v_line || 'PRIMARY KEY: ';
|
|
266
|
+
elsif v_rec.indisunique then
|
|
267
|
+
v_line := v_line || 'UNIQUE: ';
|
|
268
|
+
else
|
|
269
|
+
v_line := v_line || 'INDEX: ';
|
|
270
|
+
end if;
|
|
271
|
+
-- Extract just the column part from index definition
|
|
272
|
+
v_line := v_line || v_rec.index_name || ' ' ||
|
|
273
|
+
regexp_replace(v_rec.index_def, '^CREATE.*INDEX.*ON.*USING\s+\w+\s*', '');
|
|
274
|
+
v_lines := array_append(v_lines, v_line);
|
|
275
|
+
end loop;
|
|
276
|
+
|
|
277
|
+
if not exists (select 1 from pg_index where indrelid = v_oid) then
|
|
278
|
+
v_lines := array_append(v_lines, ' (none)');
|
|
279
|
+
end if;
|
|
280
|
+
end if;
|
|
281
|
+
|
|
282
|
+
-- Constraints (only tables can have constraints)
|
|
283
|
+
if v_relkind in ('r', 'p', 'f') then
|
|
284
|
+
v_lines := array_append(v_lines, '');
|
|
285
|
+
v_lines := array_append(v_lines, 'Constraints:');
|
|
286
|
+
v_constraint_count := 0;
|
|
287
|
+
|
|
288
|
+
for v_rec in
|
|
289
|
+
select
|
|
290
|
+
conname,
|
|
291
|
+
contype,
|
|
292
|
+
pg_get_constraintdef(oid, true) as condef
|
|
293
|
+
from pg_constraint
|
|
294
|
+
where conrelid = v_oid
|
|
295
|
+
and contype != 'p' -- skip primary key (shown with indexes)
|
|
296
|
+
order by
|
|
297
|
+
case contype when 'f' then 1 when 'u' then 2 when 'c' then 3 else 4 end,
|
|
298
|
+
conname
|
|
299
|
+
loop
|
|
300
|
+
v_constraint_count := v_constraint_count + 1;
|
|
301
|
+
v_line := ' ';
|
|
302
|
+
case v_rec.contype
|
|
303
|
+
when 'f' then v_line := v_line || 'FK: ';
|
|
304
|
+
when 'u' then v_line := v_line || 'UNIQUE: ';
|
|
305
|
+
when 'c' then v_line := v_line || 'CHECK: ';
|
|
306
|
+
else v_line := v_line || v_rec.contype || ': ';
|
|
307
|
+
end case;
|
|
308
|
+
v_line := v_line || v_rec.conname || ' ' || v_rec.condef;
|
|
309
|
+
v_lines := array_append(v_lines, v_line);
|
|
310
|
+
end loop;
|
|
311
|
+
|
|
312
|
+
if v_constraint_count = 0 then
|
|
313
|
+
v_lines := array_append(v_lines, ' (none)');
|
|
314
|
+
end if;
|
|
315
|
+
|
|
316
|
+
-- Foreign keys referencing this table
|
|
317
|
+
v_lines := array_append(v_lines, '');
|
|
318
|
+
v_lines := array_append(v_lines, 'Referenced by:');
|
|
319
|
+
v_constraint_count := 0;
|
|
320
|
+
|
|
321
|
+
for v_rec in
|
|
322
|
+
select
|
|
323
|
+
conname,
|
|
324
|
+
conrelid::regclass::text as from_table,
|
|
325
|
+
pg_get_constraintdef(oid, true) as condef
|
|
326
|
+
from pg_constraint
|
|
327
|
+
where confrelid = v_oid
|
|
328
|
+
and contype = 'f'
|
|
329
|
+
order by conrelid::regclass::text, conname
|
|
330
|
+
loop
|
|
331
|
+
v_constraint_count := v_constraint_count + 1;
|
|
332
|
+
v_lines := array_append(v_lines, format(' %s.%s %s',
|
|
333
|
+
v_rec.from_table, v_rec.conname, v_rec.condef));
|
|
334
|
+
end loop;
|
|
335
|
+
|
|
336
|
+
if v_constraint_count = 0 then
|
|
337
|
+
v_lines := array_append(v_lines, ' (none)');
|
|
338
|
+
end if;
|
|
339
|
+
end if;
|
|
340
|
+
|
|
341
|
+
-- Partition info (if partitioned table or partition)
|
|
342
|
+
if v_relkind = 'p' then
|
|
343
|
+
-- This is a partitioned table - show partition key and partitions
|
|
344
|
+
v_lines := array_append(v_lines, '');
|
|
345
|
+
v_lines := array_append(v_lines, 'Partitioning:');
|
|
346
|
+
|
|
347
|
+
select format(' %s BY %s',
|
|
348
|
+
case partstrat
|
|
349
|
+
when 'r' then 'RANGE'
|
|
350
|
+
when 'l' then 'LIST'
|
|
351
|
+
when 'h' then 'HASH'
|
|
352
|
+
else partstrat
|
|
353
|
+
end,
|
|
354
|
+
pg_get_partkeydef(v_oid)
|
|
355
|
+
)
|
|
356
|
+
into v_line
|
|
357
|
+
from pg_partitioned_table
|
|
358
|
+
where partrelid = v_oid;
|
|
359
|
+
|
|
360
|
+
if v_line is not null then
|
|
361
|
+
v_lines := array_append(v_lines, v_line);
|
|
362
|
+
end if;
|
|
363
|
+
|
|
364
|
+
-- List partitions
|
|
365
|
+
v_constraint_count := 0;
|
|
366
|
+
for v_rec in
|
|
367
|
+
select
|
|
368
|
+
c.oid::regclass::text as partition_name,
|
|
369
|
+
pg_get_expr(c.relpartbound, c.oid) as partition_bound,
|
|
370
|
+
c.relpages,
|
|
371
|
+
c.reltuples
|
|
372
|
+
from pg_inherits i
|
|
373
|
+
join pg_class c on c.oid = i.inhrelid
|
|
374
|
+
where i.inhparent = v_oid
|
|
375
|
+
order by c.oid::regclass::text
|
|
376
|
+
loop
|
|
377
|
+
v_constraint_count := v_constraint_count + 1;
|
|
378
|
+
v_lines := array_append(v_lines, format(' %s: %s (relpages: %s, reltuples: %s)',
|
|
379
|
+
v_rec.partition_name, v_rec.partition_bound,
|
|
380
|
+
v_rec.relpages,
|
|
381
|
+
case when v_rec.reltuples < 0 then '-1' else v_rec.reltuples::bigint::text end
|
|
382
|
+
));
|
|
383
|
+
end loop;
|
|
384
|
+
|
|
385
|
+
v_lines := array_append(v_lines, format(' Total partitions: %s', v_constraint_count));
|
|
386
|
+
|
|
387
|
+
elsif exists (select 1 from pg_inherits where inhrelid = v_oid) then
|
|
388
|
+
-- This is a partition - show parent and bound
|
|
389
|
+
v_lines := array_append(v_lines, '');
|
|
390
|
+
v_lines := array_append(v_lines, 'Partition of:');
|
|
391
|
+
|
|
392
|
+
select format(' %s FOR VALUES %s',
|
|
393
|
+
i.inhparent::regclass::text,
|
|
394
|
+
pg_get_expr(c.relpartbound, c.oid)
|
|
395
|
+
)
|
|
396
|
+
into v_line
|
|
397
|
+
from pg_inherits i
|
|
398
|
+
join pg_class c on c.oid = i.inhrelid
|
|
399
|
+
where i.inhrelid = v_oid;
|
|
400
|
+
|
|
401
|
+
if v_line is not null then
|
|
402
|
+
v_lines := array_append(v_lines, v_line);
|
|
403
|
+
end if;
|
|
404
|
+
end if;
|
|
405
|
+
|
|
406
|
+
result := array_to_string(v_lines, e'\n');
|
|
407
|
+
end;
|
|
408
|
+
$$;
|
|
409
|
+
|
|
410
|
+
comment on function postgres_ai.table_describe(text) is
|
|
411
|
+
'Returns comprehensive table information in compact text format for LLM analysis';
|
|
412
|
+
|
|
413
|
+
grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
|
|
414
|
+
|
|
415
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Role creation / password update (template-filled by cli/lib/init.ts)
|
|
2
|
+
--
|
|
3
|
+
-- Always uses a race-safe pattern (create if missing, then always alter to set the password):
|
|
4
|
+
-- do $$ begin
|
|
5
|
+
-- if not exists (select 1 from pg_catalog.pg_roles where rolname = '...') then
|
|
6
|
+
-- begin
|
|
7
|
+
-- create user "..." with password '...';
|
|
8
|
+
-- exception when duplicate_object then
|
|
9
|
+
-- null;
|
|
10
|
+
-- end;
|
|
11
|
+
-- end if;
|
|
12
|
+
-- alter user "..." with password '...';
|
|
13
|
+
-- end $$;
|
|
14
|
+
{{ROLE_STMT}}
|
|
15
|
+
|
|
16
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Required permissions for postgres_ai monitoring user (template-filled by cli/lib/init.ts)
|
|
2
|
+
|
|
3
|
+
-- Allow connect
|
|
4
|
+
grant connect on database {{DB_IDENT}} to {{ROLE_IDENT}};
|
|
5
|
+
|
|
6
|
+
-- Standard monitoring privileges
|
|
7
|
+
grant pg_monitor to {{ROLE_IDENT}};
|
|
8
|
+
grant select on pg_catalog.pg_index to {{ROLE_IDENT}};
|
|
9
|
+
|
|
10
|
+
-- Create postgres_ai schema for our objects
|
|
11
|
+
create schema if not exists postgres_ai;
|
|
12
|
+
grant usage on schema postgres_ai to {{ROLE_IDENT}};
|
|
13
|
+
|
|
14
|
+
-- For bloat analysis: expose pg_statistic via a view
|
|
15
|
+
create or replace view postgres_ai.pg_statistic as
|
|
16
|
+
select
|
|
17
|
+
n.nspname as schemaname,
|
|
18
|
+
c.relname as tablename,
|
|
19
|
+
a.attname,
|
|
20
|
+
s.stanullfrac as null_frac,
|
|
21
|
+
s.stawidth as avg_width,
|
|
22
|
+
false as inherited
|
|
23
|
+
from pg_catalog.pg_statistic s
|
|
24
|
+
join pg_catalog.pg_class c on c.oid = s.starelid
|
|
25
|
+
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
26
|
+
join pg_catalog.pg_attribute a on a.attrelid = s.starelid and a.attnum = s.staattnum
|
|
27
|
+
where a.attnum > 0 and not a.attisdropped;
|
|
28
|
+
|
|
29
|
+
grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
|
|
30
|
+
|
|
31
|
+
-- Hardened clusters sometimes revoke PUBLIC on schema public
|
|
32
|
+
grant usage on schema public to {{ROLE_IDENT}};
|
|
33
|
+
|
|
34
|
+
-- Keep search_path predictable; postgres_ai first so our objects are found
|
|
35
|
+
alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
|
|
36
|
+
|
|
37
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Optional permissions for self-managed Postgres (best effort)
|
|
2
|
+
|
|
3
|
+
grant execute on function pg_catalog.pg_stat_file(text) to {{ROLE_IDENT}};
|
|
4
|
+
grant execute on function pg_catalog.pg_stat_file(text, boolean) to {{ROLE_IDENT}};
|
|
5
|
+
grant execute on function pg_catalog.pg_ls_dir(text) to {{ROLE_IDENT}};
|
|
6
|
+
grant execute on function pg_catalog.pg_ls_dir(text, boolean, boolean) to {{ROLE_IDENT}};
|
|
7
|
+
|
|
8
|
+
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
-- Helper functions for postgres_ai monitoring user (template-filled by cli/lib/init.ts)
|
|
2
|
+
-- These functions use SECURITY DEFINER to allow the monitoring user to perform
|
|
3
|
+
-- operations they don't have direct permissions for.
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* pgai_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
|
+
* Usage examples:
|
|
12
|
+
* -- Basic generic plan
|
|
13
|
+
* select postgres_ai.explain_generic('select * from users where id = $1');
|
|
14
|
+
*
|
|
15
|
+
* -- JSON format
|
|
16
|
+
* select postgres_ai.explain_generic('select * from users where id = $1', 'json');
|
|
17
|
+
*
|
|
18
|
+
* -- Test a hypothetical index
|
|
19
|
+
* select postgres_ai.explain_generic(
|
|
20
|
+
* 'select * from users where email = $1',
|
|
21
|
+
* 'text',
|
|
22
|
+
* 'create index on users (email)'
|
|
23
|
+
* );
|
|
24
|
+
*/
|
|
25
|
+
create or replace function postgres_ai.explain_generic(
|
|
26
|
+
in query text,
|
|
27
|
+
in format text default 'text',
|
|
28
|
+
in hypopg_index text default null,
|
|
29
|
+
out result text
|
|
30
|
+
)
|
|
31
|
+
language plpgsql
|
|
32
|
+
security definer
|
|
33
|
+
set search_path = pg_catalog, public
|
|
34
|
+
as $$
|
|
35
|
+
declare
|
|
36
|
+
v_line record;
|
|
37
|
+
v_lines text[] := '{}';
|
|
38
|
+
v_explain_query text;
|
|
39
|
+
v_hypo_result record;
|
|
40
|
+
v_version int;
|
|
41
|
+
v_hypopg_available boolean;
|
|
42
|
+
begin
|
|
43
|
+
-- Check PostgreSQL version (generic_plan requires 16+)
|
|
44
|
+
select current_setting('server_version_num')::int into v_version;
|
|
45
|
+
|
|
46
|
+
if v_version < 160000 then
|
|
47
|
+
raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
|
|
48
|
+
current_setting('server_version');
|
|
49
|
+
end if;
|
|
50
|
+
|
|
51
|
+
-- Check if HypoPG extension is available
|
|
52
|
+
if hypopg_index is not null then
|
|
53
|
+
select exists(
|
|
54
|
+
select 1 from pg_extension where extname = 'hypopg'
|
|
55
|
+
) into v_hypopg_available;
|
|
56
|
+
|
|
57
|
+
if not v_hypopg_available then
|
|
58
|
+
raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
|
|
59
|
+
end if;
|
|
60
|
+
|
|
61
|
+
-- Create hypothetical index
|
|
62
|
+
select * into v_hypo_result from hypopg_create_index(hypopg_index);
|
|
63
|
+
raise notice 'Created hypothetical index: % (oid: %)',
|
|
64
|
+
v_hypo_result.indexname, v_hypo_result.indexrelid;
|
|
65
|
+
end if;
|
|
66
|
+
|
|
67
|
+
-- Build and execute explain query based on format
|
|
68
|
+
-- Output is preserved exactly as EXPLAIN returns it
|
|
69
|
+
begin
|
|
70
|
+
if lower(format) = 'json' then
|
|
71
|
+
v_explain_query := 'explain (verbose, settings, generic_plan, format json) ' || query;
|
|
72
|
+
execute v_explain_query into result;
|
|
73
|
+
else
|
|
74
|
+
v_explain_query := 'explain (verbose, settings, generic_plan) ' || query;
|
|
75
|
+
for v_line in execute v_explain_query loop
|
|
76
|
+
v_lines := array_append(v_lines, v_line."QUERY PLAN");
|
|
77
|
+
end loop;
|
|
78
|
+
result := array_to_string(v_lines, e'\n');
|
|
79
|
+
end if;
|
|
80
|
+
exception when others then
|
|
81
|
+
-- Clean up hypothetical index before re-raising
|
|
82
|
+
if hypopg_index is not null then
|
|
83
|
+
perform hypopg_reset();
|
|
84
|
+
end if;
|
|
85
|
+
raise;
|
|
86
|
+
end;
|
|
87
|
+
|
|
88
|
+
-- Clean up hypothetical index
|
|
89
|
+
if hypopg_index is not null then
|
|
90
|
+
perform hypopg_reset();
|
|
91
|
+
end if;
|
|
92
|
+
end;
|
|
93
|
+
$$;
|
|
94
|
+
|
|
95
|
+
comment on function postgres_ai.explain_generic(text, text, text) is
|
|
96
|
+
'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
|
|
97
|
+
|
|
98
|
+
-- Grant execute to the monitoring user
|
|
99
|
+
grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
|
|
100
|
+
|
|
101
|
+
/*
|
|
102
|
+
* table_describe
|
|
103
|
+
*
|
|
104
|
+
* Collects comprehensive information about a table for LLM analysis.
|
|
105
|
+
* Returns a compact text format with:
|
|
106
|
+
* - Table metadata (type, size estimates)
|
|
107
|
+
* - Columns (name, type, nullable, default)
|
|
108
|
+
* - Indexes
|
|
109
|
+
* - Constraints (PK, FK, unique, check)
|
|
110
|
+
* - Maintenance stats (vacuum/analyze times)
|
|
111
|
+
*
|
|
112
|
+
* Usage:
|
|
113
|
+
* select postgres_ai.table_describe('public.users');
|
|
114
|
+
* select postgres_ai.table_describe('my_table'); -- uses search_path
|
|
115
|
+
*/
|
|
116
|
+
create or replace function postgres_ai.table_describe(
|
|
117
|
+
in table_name text,
|
|
118
|
+
out result text
|
|
119
|
+
)
|
|
120
|
+
language plpgsql
|
|
121
|
+
security definer
|
|
122
|
+
set search_path = pg_catalog, public
|
|
123
|
+
as $$
|
|
124
|
+
declare
|
|
125
|
+
v_oid oid;
|
|
126
|
+
v_schema text;
|
|
127
|
+
v_table text;
|
|
128
|
+
v_relkind char;
|
|
129
|
+
v_relpages int;
|
|
130
|
+
v_reltuples float;
|
|
131
|
+
v_lines text[] := '{}';
|
|
132
|
+
v_line text;
|
|
133
|
+
v_rec record;
|
|
134
|
+
v_constraint_count int := 0;
|
|
135
|
+
begin
|
|
136
|
+
-- Resolve table name to OID (handles schema-qualified and search_path)
|
|
137
|
+
v_oid := table_name::regclass::oid;
|
|
138
|
+
|
|
139
|
+
-- Get basic table info
|
|
140
|
+
select
|
|
141
|
+
n.nspname,
|
|
142
|
+
c.relname,
|
|
143
|
+
c.relkind,
|
|
144
|
+
c.relpages,
|
|
145
|
+
c.reltuples
|
|
146
|
+
into v_schema, v_table, v_relkind, v_relpages, v_reltuples
|
|
147
|
+
from pg_class c
|
|
148
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
149
|
+
where c.oid = v_oid;
|
|
150
|
+
|
|
151
|
+
-- Validate object type - only tables, views, and materialized views are supported
|
|
152
|
+
if v_relkind not in ('r', 'p', 'v', 'm', 'f') then
|
|
153
|
+
raise exception 'table_describe does not support % (relkind=%)',
|
|
154
|
+
case v_relkind
|
|
155
|
+
when 'i' then 'indexes'
|
|
156
|
+
when 'I' then 'partitioned indexes'
|
|
157
|
+
when 'S' then 'sequences'
|
|
158
|
+
when 't' then 'TOAST tables'
|
|
159
|
+
when 'c' then 'composite types'
|
|
160
|
+
else format('objects of type "%s"', v_relkind)
|
|
161
|
+
end,
|
|
162
|
+
v_relkind;
|
|
163
|
+
end if;
|
|
164
|
+
|
|
165
|
+
-- Header
|
|
166
|
+
v_lines := array_append(v_lines, format('Table: %I.%I', v_schema, v_table));
|
|
167
|
+
v_lines := array_append(v_lines, format('Type: %s | relpages: %s | reltuples: %s',
|
|
168
|
+
case v_relkind
|
|
169
|
+
when 'r' then 'table'
|
|
170
|
+
when 'p' then 'partitioned table'
|
|
171
|
+
when 'v' then 'view'
|
|
172
|
+
when 'm' then 'materialized view'
|
|
173
|
+
when 'f' then 'foreign table'
|
|
174
|
+
end,
|
|
175
|
+
v_relpages,
|
|
176
|
+
case when v_reltuples < 0 then '-1' else v_reltuples::bigint::text end
|
|
177
|
+
));
|
|
178
|
+
|
|
179
|
+
-- Vacuum/analyze stats (only for tables and materialized views, not views)
|
|
180
|
+
if v_relkind in ('r', 'p', 'm', 'f') then
|
|
181
|
+
select
|
|
182
|
+
format('Vacuum: %s (auto: %s) | Analyze: %s (auto: %s)',
|
|
183
|
+
coalesce(to_char(last_vacuum at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
|
|
184
|
+
coalesce(to_char(last_autovacuum at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
|
|
185
|
+
coalesce(to_char(last_analyze at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
|
|
186
|
+
coalesce(to_char(last_autoanalyze at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never')
|
|
187
|
+
)
|
|
188
|
+
into v_line
|
|
189
|
+
from pg_stat_all_tables
|
|
190
|
+
where relid = v_oid;
|
|
191
|
+
|
|
192
|
+
if v_line is not null then
|
|
193
|
+
v_lines := array_append(v_lines, v_line);
|
|
194
|
+
end if;
|
|
195
|
+
end if;
|
|
196
|
+
|
|
197
|
+
v_lines := array_append(v_lines, '');
|
|
198
|
+
|
|
199
|
+
-- Columns
|
|
200
|
+
v_lines := array_append(v_lines, 'Columns:');
|
|
201
|
+
for v_rec in
|
|
202
|
+
select
|
|
203
|
+
a.attname,
|
|
204
|
+
format_type(a.atttypid, a.atttypmod) as data_type,
|
|
205
|
+
a.attnotnull,
|
|
206
|
+
(select pg_get_expr(d.adbin, d.adrelid, true)
|
|
207
|
+
from pg_attrdef d
|
|
208
|
+
where d.adrelid = a.attrelid and d.adnum = a.attnum and a.atthasdef) as default_val,
|
|
209
|
+
a.attidentity,
|
|
210
|
+
a.attgenerated
|
|
211
|
+
from pg_attribute a
|
|
212
|
+
where a.attrelid = v_oid
|
|
213
|
+
and a.attnum > 0
|
|
214
|
+
and not a.attisdropped
|
|
215
|
+
order by a.attnum
|
|
216
|
+
loop
|
|
217
|
+
v_line := format(' %s %s', v_rec.attname, v_rec.data_type);
|
|
218
|
+
|
|
219
|
+
if v_rec.attnotnull then
|
|
220
|
+
v_line := v_line || ' NOT NULL';
|
|
221
|
+
end if;
|
|
222
|
+
|
|
223
|
+
if v_rec.attidentity = 'a' then
|
|
224
|
+
v_line := v_line || ' GENERATED ALWAYS AS IDENTITY';
|
|
225
|
+
elsif v_rec.attidentity = 'd' then
|
|
226
|
+
v_line := v_line || ' GENERATED BY DEFAULT AS IDENTITY';
|
|
227
|
+
elsif v_rec.attgenerated = 's' then
|
|
228
|
+
v_line := v_line || format(' GENERATED ALWAYS AS (%s) STORED', v_rec.default_val);
|
|
229
|
+
elsif v_rec.default_val is not null then
|
|
230
|
+
v_line := v_line || format(' DEFAULT %s', v_rec.default_val);
|
|
231
|
+
end if;
|
|
232
|
+
|
|
233
|
+
v_lines := array_append(v_lines, v_line);
|
|
234
|
+
end loop;
|
|
235
|
+
|
|
236
|
+
-- View definition (for views and materialized views)
|
|
237
|
+
if v_relkind in ('v', 'm') then
|
|
238
|
+
v_lines := array_append(v_lines, '');
|
|
239
|
+
v_lines := array_append(v_lines, 'Definition:');
|
|
240
|
+
v_line := pg_get_viewdef(v_oid, true);
|
|
241
|
+
if v_line is not null then
|
|
242
|
+
-- Indent the view definition
|
|
243
|
+
v_line := ' ' || replace(v_line, e'\n', e'\n ');
|
|
244
|
+
v_lines := array_append(v_lines, v_line);
|
|
245
|
+
end if;
|
|
246
|
+
end if;
|
|
247
|
+
|
|
248
|
+
-- Indexes (tables, partitioned tables, and materialized views can have indexes)
|
|
249
|
+
if v_relkind in ('r', 'p', 'm') then
|
|
250
|
+
v_lines := array_append(v_lines, '');
|
|
251
|
+
v_lines := array_append(v_lines, 'Indexes:');
|
|
252
|
+
for v_rec in
|
|
253
|
+
select
|
|
254
|
+
i.relname as index_name,
|
|
255
|
+
pg_get_indexdef(i.oid) as index_def,
|
|
256
|
+
ix.indisprimary,
|
|
257
|
+
ix.indisunique
|
|
258
|
+
from pg_index ix
|
|
259
|
+
join pg_class i on i.oid = ix.indexrelid
|
|
260
|
+
where ix.indrelid = v_oid
|
|
261
|
+
order by ix.indisprimary desc, ix.indisunique desc, i.relname
|
|
262
|
+
loop
|
|
263
|
+
v_line := ' ';
|
|
264
|
+
if v_rec.indisprimary then
|
|
265
|
+
v_line := v_line || 'PRIMARY KEY: ';
|
|
266
|
+
elsif v_rec.indisunique then
|
|
267
|
+
v_line := v_line || 'UNIQUE: ';
|
|
268
|
+
else
|
|
269
|
+
v_line := v_line || 'INDEX: ';
|
|
270
|
+
end if;
|
|
271
|
+
-- Extract just the column part from index definition
|
|
272
|
+
v_line := v_line || v_rec.index_name || ' ' ||
|
|
273
|
+
regexp_replace(v_rec.index_def, '^CREATE.*INDEX.*ON.*USING\s+\w+\s*', '');
|
|
274
|
+
v_lines := array_append(v_lines, v_line);
|
|
275
|
+
end loop;
|
|
276
|
+
|
|
277
|
+
if not exists (select 1 from pg_index where indrelid = v_oid) then
|
|
278
|
+
v_lines := array_append(v_lines, ' (none)');
|
|
279
|
+
end if;
|
|
280
|
+
end if;
|
|
281
|
+
|
|
282
|
+
-- Constraints (only tables can have constraints)
|
|
283
|
+
if v_relkind in ('r', 'p', 'f') then
|
|
284
|
+
v_lines := array_append(v_lines, '');
|
|
285
|
+
v_lines := array_append(v_lines, 'Constraints:');
|
|
286
|
+
v_constraint_count := 0;
|
|
287
|
+
|
|
288
|
+
for v_rec in
|
|
289
|
+
select
|
|
290
|
+
conname,
|
|
291
|
+
contype,
|
|
292
|
+
pg_get_constraintdef(oid, true) as condef
|
|
293
|
+
from pg_constraint
|
|
294
|
+
where conrelid = v_oid
|
|
295
|
+
and contype != 'p' -- skip primary key (shown with indexes)
|
|
296
|
+
order by
|
|
297
|
+
case contype when 'f' then 1 when 'u' then 2 when 'c' then 3 else 4 end,
|
|
298
|
+
conname
|
|
299
|
+
loop
|
|
300
|
+
v_constraint_count := v_constraint_count + 1;
|
|
301
|
+
v_line := ' ';
|
|
302
|
+
case v_rec.contype
|
|
303
|
+
when 'f' then v_line := v_line || 'FK: ';
|
|
304
|
+
when 'u' then v_line := v_line || 'UNIQUE: ';
|
|
305
|
+
when 'c' then v_line := v_line || 'CHECK: ';
|
|
306
|
+
else v_line := v_line || v_rec.contype || ': ';
|
|
307
|
+
end case;
|
|
308
|
+
v_line := v_line || v_rec.conname || ' ' || v_rec.condef;
|
|
309
|
+
v_lines := array_append(v_lines, v_line);
|
|
310
|
+
end loop;
|
|
311
|
+
|
|
312
|
+
if v_constraint_count = 0 then
|
|
313
|
+
v_lines := array_append(v_lines, ' (none)');
|
|
314
|
+
end if;
|
|
315
|
+
|
|
316
|
+
-- Foreign keys referencing this table
|
|
317
|
+
v_lines := array_append(v_lines, '');
|
|
318
|
+
v_lines := array_append(v_lines, 'Referenced by:');
|
|
319
|
+
v_constraint_count := 0;
|
|
320
|
+
|
|
321
|
+
for v_rec in
|
|
322
|
+
select
|
|
323
|
+
conname,
|
|
324
|
+
conrelid::regclass::text as from_table,
|
|
325
|
+
pg_get_constraintdef(oid, true) as condef
|
|
326
|
+
from pg_constraint
|
|
327
|
+
where confrelid = v_oid
|
|
328
|
+
and contype = 'f'
|
|
329
|
+
order by conrelid::regclass::text, conname
|
|
330
|
+
loop
|
|
331
|
+
v_constraint_count := v_constraint_count + 1;
|
|
332
|
+
v_lines := array_append(v_lines, format(' %s.%s %s',
|
|
333
|
+
v_rec.from_table, v_rec.conname, v_rec.condef));
|
|
334
|
+
end loop;
|
|
335
|
+
|
|
336
|
+
if v_constraint_count = 0 then
|
|
337
|
+
v_lines := array_append(v_lines, ' (none)');
|
|
338
|
+
end if;
|
|
339
|
+
end if;
|
|
340
|
+
|
|
341
|
+
-- Partition info (if partitioned table or partition)
|
|
342
|
+
if v_relkind = 'p' then
|
|
343
|
+
-- This is a partitioned table - show partition key and partitions
|
|
344
|
+
v_lines := array_append(v_lines, '');
|
|
345
|
+
v_lines := array_append(v_lines, 'Partitioning:');
|
|
346
|
+
|
|
347
|
+
select format(' %s BY %s',
|
|
348
|
+
case partstrat
|
|
349
|
+
when 'r' then 'RANGE'
|
|
350
|
+
when 'l' then 'LIST'
|
|
351
|
+
when 'h' then 'HASH'
|
|
352
|
+
else partstrat
|
|
353
|
+
end,
|
|
354
|
+
pg_get_partkeydef(v_oid)
|
|
355
|
+
)
|
|
356
|
+
into v_line
|
|
357
|
+
from pg_partitioned_table
|
|
358
|
+
where partrelid = v_oid;
|
|
359
|
+
|
|
360
|
+
if v_line is not null then
|
|
361
|
+
v_lines := array_append(v_lines, v_line);
|
|
362
|
+
end if;
|
|
363
|
+
|
|
364
|
+
-- List partitions
|
|
365
|
+
v_constraint_count := 0;
|
|
366
|
+
for v_rec in
|
|
367
|
+
select
|
|
368
|
+
c.oid::regclass::text as partition_name,
|
|
369
|
+
pg_get_expr(c.relpartbound, c.oid) as partition_bound,
|
|
370
|
+
c.relpages,
|
|
371
|
+
c.reltuples
|
|
372
|
+
from pg_inherits i
|
|
373
|
+
join pg_class c on c.oid = i.inhrelid
|
|
374
|
+
where i.inhparent = v_oid
|
|
375
|
+
order by c.oid::regclass::text
|
|
376
|
+
loop
|
|
377
|
+
v_constraint_count := v_constraint_count + 1;
|
|
378
|
+
v_lines := array_append(v_lines, format(' %s: %s (relpages: %s, reltuples: %s)',
|
|
379
|
+
v_rec.partition_name, v_rec.partition_bound,
|
|
380
|
+
v_rec.relpages,
|
|
381
|
+
case when v_rec.reltuples < 0 then '-1' else v_rec.reltuples::bigint::text end
|
|
382
|
+
));
|
|
383
|
+
end loop;
|
|
384
|
+
|
|
385
|
+
v_lines := array_append(v_lines, format(' Total partitions: %s', v_constraint_count));
|
|
386
|
+
|
|
387
|
+
elsif exists (select 1 from pg_inherits where inhrelid = v_oid) then
|
|
388
|
+
-- This is a partition - show parent and bound
|
|
389
|
+
v_lines := array_append(v_lines, '');
|
|
390
|
+
v_lines := array_append(v_lines, 'Partition of:');
|
|
391
|
+
|
|
392
|
+
select format(' %s FOR VALUES %s',
|
|
393
|
+
i.inhparent::regclass::text,
|
|
394
|
+
pg_get_expr(c.relpartbound, c.oid)
|
|
395
|
+
)
|
|
396
|
+
into v_line
|
|
397
|
+
from pg_inherits i
|
|
398
|
+
join pg_class c on c.oid = i.inhrelid
|
|
399
|
+
where i.inhrelid = v_oid;
|
|
400
|
+
|
|
401
|
+
if v_line is not null then
|
|
402
|
+
v_lines := array_append(v_lines, v_line);
|
|
403
|
+
end if;
|
|
404
|
+
end if;
|
|
405
|
+
|
|
406
|
+
result := array_to_string(v_lines, e'\n');
|
|
407
|
+
end;
|
|
408
|
+
$$;
|
|
409
|
+
|
|
410
|
+
comment on function postgres_ai.table_describe(text) is
|
|
411
|
+
'Returns comprehensive table information in compact text format for LLM analysis';
|
|
412
|
+
|
|
413
|
+
grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
|
|
414
|
+
|
|
415
|
+
|
package/lib/checkup.ts
CHANGED
|
@@ -117,6 +117,8 @@ export interface InvalidIndex {
|
|
|
117
117
|
relation_name: string;
|
|
118
118
|
index_size_bytes: number;
|
|
119
119
|
index_size_pretty: string;
|
|
120
|
+
/** Full CREATE INDEX statement from pg_get_indexdef(), useful for DROP/CREATE migrations */
|
|
121
|
+
index_definition: string;
|
|
120
122
|
supports_fk: boolean;
|
|
121
123
|
}
|
|
122
124
|
|
|
@@ -581,6 +583,7 @@ export async function getInvalidIndexes(client: Client, pgMajorVersion: number =
|
|
|
581
583
|
relation_name: String(transformed.relation_name || ""),
|
|
582
584
|
index_size_bytes: indexSizeBytes,
|
|
583
585
|
index_size_pretty: formatBytes(indexSizeBytes),
|
|
586
|
+
index_definition: String(transformed.index_definition || ""),
|
|
584
587
|
supports_fk: toBool(transformed.supports_fk),
|
|
585
588
|
};
|
|
586
589
|
});
|
package/lib/init.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "crypto";
|
|
2
|
-
import { URL } from "url";
|
|
2
|
+
import { URL, fileURLToPath } from "url";
|
|
3
3
|
import type { ConnectionOptions as TlsConnectionOptions } from "tls";
|
|
4
4
|
import type { Client as PgClient } from "pg";
|
|
5
5
|
import * as fs from "fs";
|
|
@@ -163,9 +163,15 @@ function sqlDir(): string {
|
|
|
163
163
|
// Handle both development and production paths
|
|
164
164
|
// Development: lib/init.ts -> ../sql
|
|
165
165
|
// Production (bundled): dist/bin/postgres-ai.js -> ../sql (copied during build)
|
|
166
|
+
//
|
|
167
|
+
// IMPORTANT: Use import.meta.url instead of __dirname because bundlers (bun/esbuild)
|
|
168
|
+
// bake in __dirname at build time, while import.meta.url resolves at runtime.
|
|
169
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
170
|
+
const currentDir = path.dirname(currentFile);
|
|
171
|
+
|
|
166
172
|
const candidates = [
|
|
167
|
-
path.resolve(
|
|
168
|
-
path.resolve(
|
|
173
|
+
path.resolve(currentDir, "..", "sql"), // bundled: dist/bin -> dist/sql
|
|
174
|
+
path.resolve(currentDir, "..", "..", "sql"), // dev from lib: lib -> ../sql
|
|
169
175
|
];
|
|
170
176
|
|
|
171
177
|
for (const candidate of candidates) {
|
package/lib/metrics-embedded.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// AUTO-GENERATED FILE - DO NOT EDIT
|
|
2
2
|
// Generated from config/pgwatch-prometheus/metrics.yml by scripts/embed-metrics.ts
|
|
3
|
-
// Generated at: 2025-12-
|
|
3
|
+
// Generated at: 2025-12-29T19:46:00.537Z
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Metric definition from metrics.yml
|
|
@@ -47,7 +47,7 @@ export const METRICS: Record<string, MetricDefinition> = {
|
|
|
47
47
|
"pg_invalid_indexes": {
|
|
48
48
|
description: "This metric identifies invalid indexes in the database. It provides insights into the number of invalid indexes and their details. This metric helps administrators identify and fix invalid indexes to improve database performance.",
|
|
49
49
|
sqls: {
|
|
50
|
-
11: "with fk_indexes as ( /* pgwatch_generated */\n select\n schemaname as tag_schema_name,\n (indexrelid::regclass)::text as tag_index_name,\n (relid::regclass)::text as tag_table_name,\n (confrelid::regclass)::text as tag_fk_table_ref,\n array_to_string(indclass, ', ') as tag_opclasses\n from\n pg_stat_all_indexes\n join pg_index using (indexrelid)\n left join pg_constraint\n on array_to_string(indkey, ',') = array_to_string(conkey, ',')\n and schemaname = (connamespace::regnamespace)::text\n and conrelid = relid\n and contype = 'f'\n where idx_scan = 0\n and indisunique is false\n and conkey is not null --conkey is not null then true else false end as is_fk_idx\n), data as (\n select\n pci.relname as tag_index_name,\n pn.nspname as tag_schema_name,\n pct.relname as tag_table_name,\n quote_ident(pn.nspname) as tag_schema_name,\n quote_ident(pci.relname) as tag_index_name,\n quote_ident(pct.relname) as tag_table_name,\n coalesce(nullif(quote_ident(pn.nspname), 'public') || '.', '') || quote_ident(pct.relname) as tag_relation_name,\n pg_relation_size(pidx.indexrelid) index_size_bytes,\n ((\n select count(1)\n from fk_indexes fi\n where\n fi.tag_fk_table_ref = pct.relname\n and fi.tag_opclasses like (array_to_string(pidx.indclass, ', ') || '%')\n ) > 0)::int as supports_fk\n from pg_index pidx\n join pg_class as pci on pci.oid = pidx.indexrelid\n join pg_class as pct on pct.oid = pidx.indrelid\n left join pg_namespace pn on pn.oid = pct.relnamespace\n where pidx.indisvalid = false\n), data_total as (\n select\n sum(index_size_bytes) as index_size_bytes_sum\n from data\n), num_data as (\n select\n row_number() over () num,\n data.*\n from data\n)\nselect\n (extract(epoch from now()) * 1e9)::int8 as epoch_ns,\n current_database() as tag_datname,\n num_data.*\nfrom num_data\nlimit 1000;\n",
|
|
50
|
+
11: "with fk_indexes as ( /* pgwatch_generated */\n select\n schemaname as tag_schema_name,\n (indexrelid::regclass)::text as tag_index_name,\n (relid::regclass)::text as tag_table_name,\n (confrelid::regclass)::text as tag_fk_table_ref,\n array_to_string(indclass, ', ') as tag_opclasses\n from\n pg_stat_all_indexes\n join pg_index using (indexrelid)\n left join pg_constraint\n on array_to_string(indkey, ',') = array_to_string(conkey, ',')\n and schemaname = (connamespace::regnamespace)::text\n and conrelid = relid\n and contype = 'f'\n where idx_scan = 0\n and indisunique is false\n and conkey is not null --conkey is not null then true else false end as is_fk_idx\n), data as (\n select\n pci.relname as tag_index_name,\n pn.nspname as tag_schema_name,\n pct.relname as tag_table_name,\n quote_ident(pn.nspname) as tag_schema_name,\n quote_ident(pci.relname) as tag_index_name,\n quote_ident(pct.relname) as tag_table_name,\n coalesce(nullif(quote_ident(pn.nspname), 'public') || '.', '') || quote_ident(pct.relname) as tag_relation_name,\n pg_get_indexdef(pidx.indexrelid) as index_definition,\n pg_relation_size(pidx.indexrelid) index_size_bytes,\n ((\n select count(1)\n from fk_indexes fi\n where\n fi.tag_fk_table_ref = pct.relname\n and fi.tag_opclasses like (array_to_string(pidx.indclass, ', ') || '%')\n ) > 0)::int as supports_fk\n from pg_index pidx\n join pg_class as pci on pci.oid = pidx.indexrelid\n join pg_class as pct on pct.oid = pidx.indrelid\n left join pg_namespace pn on pn.oid = pct.relnamespace\n where pidx.indisvalid = false\n), data_total as (\n select\n sum(index_size_bytes) as index_size_bytes_sum\n from data\n), num_data as (\n select\n row_number() over () num,\n data.*\n from data\n)\nselect\n (extract(epoch from now()) * 1e9)::int8 as epoch_ns,\n current_database() as tag_datname,\n num_data.*\nfrom num_data\nlimit 1000;\n",
|
|
51
51
|
},
|
|
52
52
|
gauges: ["*"],
|
|
53
53
|
statement_timeout_seconds: 15,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "postgresai",
|
|
3
|
-
"version": "0.14.0-dev.
|
|
3
|
+
"version": "0.14.0-dev.56",
|
|
4
4
|
"description": "postgres_ai CLI",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"private": false,
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"embed-metrics": "bun run scripts/embed-metrics.ts",
|
|
29
|
-
"build": "bun run embed-metrics && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\"",
|
|
29
|
+
"build": "bun run embed-metrics && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql",
|
|
30
30
|
"prepublishOnly": "npm run build",
|
|
31
31
|
"start": "bun ./bin/postgres-ai.ts --help",
|
|
32
32
|
"start:node": "node ./dist/bin/postgres-ai.js --help",
|
|
@@ -253,6 +253,52 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
|
|
|
253
253
|
expect(typeof nodeResult.data).toBe("object");
|
|
254
254
|
});
|
|
255
255
|
|
|
256
|
+
test("H001 returns index_definition with CREATE INDEX statement", async () => {
|
|
257
|
+
// Create a table and an index, then mark the index as invalid
|
|
258
|
+
await client.query(`
|
|
259
|
+
CREATE TABLE IF NOT EXISTS test_invalid_idx_table (id serial PRIMARY KEY, value text);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS test_invalid_idx ON test_invalid_idx_table(value);
|
|
261
|
+
`);
|
|
262
|
+
|
|
263
|
+
// Mark the index as invalid (simulating a failed CONCURRENTLY build)
|
|
264
|
+
await client.query(`
|
|
265
|
+
UPDATE pg_index SET indisvalid = false
|
|
266
|
+
WHERE indexrelid = 'test_invalid_idx'::regclass;
|
|
267
|
+
`);
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const report = await checkup.generateH001(client, "test-node");
|
|
271
|
+
validateAgainstSchema(report, "H001");
|
|
272
|
+
|
|
273
|
+
const nodeResult = report.results["test-node"];
|
|
274
|
+
const dbName = Object.keys(nodeResult.data)[0];
|
|
275
|
+
expect(dbName).toBeTruthy();
|
|
276
|
+
|
|
277
|
+
const dbData = nodeResult.data[dbName] as any;
|
|
278
|
+
expect(dbData.invalid_indexes).toBeDefined();
|
|
279
|
+
expect(dbData.invalid_indexes.length).toBeGreaterThan(0);
|
|
280
|
+
|
|
281
|
+
// Find our test index
|
|
282
|
+
const testIndex = dbData.invalid_indexes.find(
|
|
283
|
+
(idx: any) => idx.index_name === "test_invalid_idx"
|
|
284
|
+
);
|
|
285
|
+
expect(testIndex).toBeDefined();
|
|
286
|
+
|
|
287
|
+
// Verify index_definition contains the actual CREATE INDEX statement
|
|
288
|
+
expect(testIndex.index_definition).toMatch(/^CREATE INDEX/);
|
|
289
|
+
expect(testIndex.index_definition).toContain("test_invalid_idx");
|
|
290
|
+
expect(testIndex.index_definition).toContain("test_invalid_idx_table");
|
|
291
|
+
} finally {
|
|
292
|
+
// Cleanup: restore the index and drop test objects
|
|
293
|
+
await client.query(`
|
|
294
|
+
UPDATE pg_index SET indisvalid = true
|
|
295
|
+
WHERE indexrelid = 'test_invalid_idx'::regclass;
|
|
296
|
+
DROP INDEX IF EXISTS test_invalid_idx;
|
|
297
|
+
DROP TABLE IF EXISTS test_invalid_idx_table;
|
|
298
|
+
`);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
256
302
|
test("H002 (unused indexes) has correct data structure", async () => {
|
|
257
303
|
const report = await checkup.generateH002(client, "test-node");
|
|
258
304
|
validateAgainstSchema(report, "H002");
|
package/test/checkup.test.ts
CHANGED
|
@@ -480,7 +480,7 @@ describe("H001 - Invalid indexes", () => {
|
|
|
480
480
|
test("getInvalidIndexes returns invalid indexes", async () => {
|
|
481
481
|
const mockClient = createMockClient({
|
|
482
482
|
invalidIndexesRows: [
|
|
483
|
-
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", supports_fk: false },
|
|
483
|
+
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)", supports_fk: false },
|
|
484
484
|
],
|
|
485
485
|
});
|
|
486
486
|
|
|
@@ -491,6 +491,7 @@ describe("H001 - Invalid indexes", () => {
|
|
|
491
491
|
expect(indexes[0].index_name).toBe("users_email_idx");
|
|
492
492
|
expect(indexes[0].index_size_bytes).toBe(1048576);
|
|
493
493
|
expect(indexes[0].index_size_pretty).toBeTruthy();
|
|
494
|
+
expect(indexes[0].index_definition).toMatch(/^CREATE INDEX/);
|
|
494
495
|
expect(indexes[0].relation_name).toBe("users");
|
|
495
496
|
expect(indexes[0].supports_fk).toBe(false);
|
|
496
497
|
});
|
|
@@ -502,7 +503,7 @@ describe("H001 - Invalid indexes", () => {
|
|
|
502
503
|
{ name: "server_version_num", setting: "160003" },
|
|
503
504
|
],
|
|
504
505
|
invalidIndexesRows: [
|
|
505
|
-
{ schema_name: "public", table_name: "orders", index_name: "orders_status_idx", relation_name: "orders", index_size_bytes: "2097152", supports_fk: false },
|
|
506
|
+
{ schema_name: "public", table_name: "orders", index_name: "orders_status_idx", relation_name: "orders", index_size_bytes: "2097152", index_definition: "CREATE INDEX orders_status_idx ON public.orders USING btree (status)", supports_fk: false },
|
|
506
507
|
],
|
|
507
508
|
}
|
|
508
509
|
);
|
|
@@ -30,7 +30,7 @@ const indexTestData = {
|
|
|
30
30
|
emptyRows: { invalidIndexesRows: [] },
|
|
31
31
|
dataRows: {
|
|
32
32
|
invalidIndexesRows: [
|
|
33
|
-
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", supports_fk: false },
|
|
33
|
+
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)", supports_fk: false },
|
|
34
34
|
],
|
|
35
35
|
},
|
|
36
36
|
},
|