postgresai 0.16.0-rc.2 → 0.16.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/bin/postgres-ai.ts +42 -19
- package/dist/bin/postgres-ai.js +149 -25
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -8
- package/lib/supabase.ts +0 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/auth.test.ts +30 -1
- package/test/checkup.integration.test.ts +31 -21
- package/test/checkup.test.ts +48 -3
- package/test/init.integration.test.ts +9 -79
- package/test/init.test.ts +35 -0
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `checkup --markdown` previously performed server-side conversion and sent the
|
|
8
|
+
full report JSON to the PostgresAI API even when `--no-upload` was set. The
|
|
9
|
+
flags are now mutually exclusive, and `--no-upload` prevents report data from
|
|
10
|
+
being sent to the PostgresAI API. Use `--json` or `--output` for local-only
|
|
11
|
+
output.
|
package/bin/postgres-ai.ts
CHANGED
|
@@ -335,12 +335,18 @@ function prepareUploadConfig(
|
|
|
335
335
|
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
336
336
|
return null; // Signal to exit
|
|
337
337
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
338
|
+
if (opts.markdown) {
|
|
339
|
+
console.error("Notice: no API key configured — regular report upload is disabled.");
|
|
340
|
+
console.error(" The full report JSON will still be sent to the PostgresAI API for markdown conversion.");
|
|
341
|
+
console.error(" To avoid sending report data, replace --markdown with --no-upload and --json or --output.");
|
|
342
|
+
} else {
|
|
343
|
+
// No credentials and upload not explicitly requested: fall back to
|
|
344
|
+
// local-only mode, but say so prominently — skipping the upload silently
|
|
345
|
+
// hides the fact that results never reach the Console.
|
|
346
|
+
console.error("Notice: no API key configured — results will NOT be uploaded to PostgresAI.");
|
|
347
|
+
console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
|
|
348
|
+
console.error(" To run locally without this notice, pass --no-upload.");
|
|
349
|
+
}
|
|
344
350
|
return undefined; // Skip upload, run checks locally
|
|
345
351
|
}
|
|
346
352
|
|
|
@@ -1996,7 +2002,7 @@ program
|
|
|
1996
2002
|
"project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)"
|
|
1997
2003
|
)
|
|
1998
2004
|
.option("--json", "output JSON to stdout")
|
|
1999
|
-
.option("--markdown", "output markdown
|
|
2005
|
+
.option("--markdown", "output markdown via PostgresAI API (transmits the full report JSON)")
|
|
2000
2006
|
.addHelpText(
|
|
2001
2007
|
"after",
|
|
2002
2008
|
[
|
|
@@ -2010,7 +2016,7 @@ program
|
|
|
2010
2016
|
" postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
|
|
2011
2017
|
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
2012
2018
|
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
2013
|
-
" postgresai checkup postgresql://user:pass@host:5432/db --
|
|
2019
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --markdown",
|
|
2014
2020
|
].join("\n")
|
|
2015
2021
|
)
|
|
2016
2022
|
.action(async (checkIdOrConn: string | undefined, connArg: string | undefined, opts: CheckupOptions, cmd: Command) => {
|
|
@@ -2060,9 +2066,14 @@ program
|
|
|
2060
2066
|
process.exitCode = 1;
|
|
2061
2067
|
return;
|
|
2062
2068
|
}
|
|
2063
|
-
// Note: --json, --markdown and --upload/--no-upload are independent flags.
|
|
2064
|
-
// Use --no-upload to explicitly disable upload when using --json or --markdown.
|
|
2065
2069
|
const uploadExplicitlyDisabled = opts.upload === false;
|
|
2070
|
+
if (uploadExplicitlyDisabled && shouldConvertMarkdown) {
|
|
2071
|
+
console.error("Error: --no-upload and --markdown are mutually exclusive");
|
|
2072
|
+
console.error("Markdown conversion is performed by the PostgresAI API and transmits the full report JSON.");
|
|
2073
|
+
console.error("Drop --no-upload to allow transmission, or use --json or --output for local-only output.");
|
|
2074
|
+
process.exitCode = 1;
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2066
2077
|
let shouldUpload = !uploadExplicitlyDisabled;
|
|
2067
2078
|
|
|
2068
2079
|
// Preflight: validate/create output directory BEFORE connecting / running checks.
|
|
@@ -2308,7 +2319,9 @@ program
|
|
|
2308
2319
|
|
|
2309
2320
|
console.log('\nFor details:');
|
|
2310
2321
|
console.log(' --json Output JSON');
|
|
2311
|
-
|
|
2322
|
+
if (!uploadExplicitlyDisabled) {
|
|
2323
|
+
console.log(' --markdown Output markdown via PostgresAI API');
|
|
2324
|
+
}
|
|
2312
2325
|
console.log(' --output <dir> Save to directory');
|
|
2313
2326
|
}
|
|
2314
2327
|
} catch (error) {
|
|
@@ -3931,13 +3944,9 @@ targets
|
|
|
3931
3944
|
// Authentication and API key management
|
|
3932
3945
|
const auth = program.command("auth").description("authentication and API key management");
|
|
3933
3946
|
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
.option("--set-key <key>", "store API key directly without OAuth flow")
|
|
3938
|
-
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
3939
|
-
.option("--debug", "enable debug output")
|
|
3940
|
-
.action(async (opts: { setKey?: string; port?: number; debug?: boolean }) => {
|
|
3947
|
+
type AuthLoginOptions = { setKey?: string; port?: number; debug?: boolean };
|
|
3948
|
+
|
|
3949
|
+
async function runAuthLogin(opts: AuthLoginOptions) {
|
|
3941
3950
|
// If --set-key is provided, store it directly without OAuth
|
|
3942
3951
|
if (opts.setKey) {
|
|
3943
3952
|
const trimmedKey = opts.setKey.trim();
|
|
@@ -4191,7 +4200,21 @@ auth
|
|
|
4191
4200
|
console.error(`Authentication error: ${message}`);
|
|
4192
4201
|
process.exit(1);
|
|
4193
4202
|
}
|
|
4194
|
-
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
function configureLoginCommand(command: Command): Command {
|
|
4206
|
+
return command
|
|
4207
|
+
.description("authenticate via browser (OAuth) or store API key directly")
|
|
4208
|
+
.option("--set-key <key>", "store API key directly without OAuth flow")
|
|
4209
|
+
.option("--port <port>", "local callback server port (default: random)", parseInt)
|
|
4210
|
+
.option("--debug", "enable debug output");
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
configureLoginCommand(auth.command("login", { isDefault: true }))
|
|
4214
|
+
.action(runAuthLogin);
|
|
4215
|
+
|
|
4216
|
+
configureLoginCommand(program.command("login"))
|
|
4217
|
+
.action(runAuthLogin);
|
|
4195
4218
|
|
|
4196
4219
|
auth
|
|
4197
4220
|
.command("show-key")
|
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -13425,7 +13425,7 @@ var {
|
|
|
13425
13425
|
// package.json
|
|
13426
13426
|
var package_default = {
|
|
13427
13427
|
name: "postgresai",
|
|
13428
|
-
version: "0.16.0-rc.
|
|
13428
|
+
version: "0.16.0-rc.4",
|
|
13429
13429
|
description: "postgres_ai CLI",
|
|
13430
13430
|
license: "Apache-2.0",
|
|
13431
13431
|
private: false,
|
|
@@ -16256,7 +16256,7 @@ var Result = import_lib.default.Result;
|
|
|
16256
16256
|
var TypeOverrides = import_lib.default.TypeOverrides;
|
|
16257
16257
|
var defaults = import_lib.default.defaults;
|
|
16258
16258
|
// package.json
|
|
16259
|
-
var version = "0.16.0-rc.
|
|
16259
|
+
var version = "0.16.0-rc.4";
|
|
16260
16260
|
var package_default2 = {
|
|
16261
16261
|
name: "postgresai",
|
|
16262
16262
|
version,
|
|
@@ -29452,10 +29452,6 @@ async function verifyInitSetup(params) {
|
|
|
29452
29452
|
}
|
|
29453
29453
|
}
|
|
29454
29454
|
}
|
|
29455
|
-
const explainFnRes = await params.client.query("select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok", [role]);
|
|
29456
|
-
if (!explainFnRes.rows?.[0]?.ok) {
|
|
29457
|
-
missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
|
|
29458
|
-
}
|
|
29459
29455
|
const tableDescribeFnRes = await params.client.query("select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok", [role]);
|
|
29460
29456
|
if (!tableDescribeFnRes.rows?.[0]?.ok) {
|
|
29461
29457
|
missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
|
|
@@ -29522,10 +29518,28 @@ async function checkCurrentUserPermissions(client) {
|
|
|
29522
29518
|
|
|
29523
29519
|
union all
|
|
29524
29520
|
|
|
29521
|
+
select
|
|
29522
|
+
'postgres_ai schema exists' as permission_name,
|
|
29523
|
+
'optional' as status,
|
|
29524
|
+
to_regnamespace('postgres_ai') is not null as granted
|
|
29525
|
+
|
|
29526
|
+
union all
|
|
29527
|
+
|
|
29528
|
+
select
|
|
29529
|
+
'usage on postgres_ai schema' as permission_name,
|
|
29530
|
+
'optional' as status,
|
|
29531
|
+
case
|
|
29532
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
29533
|
+
else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
|
|
29534
|
+
end as granted
|
|
29535
|
+
|
|
29536
|
+
union all
|
|
29537
|
+
|
|
29525
29538
|
select
|
|
29526
29539
|
'postgres_ai.pg_statistic view exists' as permission_name,
|
|
29527
29540
|
'optional' as status,
|
|
29528
29541
|
case
|
|
29542
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
29529
29543
|
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
29530
29544
|
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
29531
29545
|
end as granted
|
|
@@ -29536,6 +29550,7 @@ async function checkCurrentUserPermissions(client) {
|
|
|
29536
29550
|
'select on postgres_ai.pg_statistic' as permission_name,
|
|
29537
29551
|
'optional' as status,
|
|
29538
29552
|
case
|
|
29553
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
29539
29554
|
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
29540
29555
|
when to_regclass('postgres_ai.pg_statistic') is null then null
|
|
29541
29556
|
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
|
|
@@ -29555,6 +29570,10 @@ async function checkCurrentUserPermissions(client) {
|
|
|
29555
29570
|
when permission_name like 'select on pg_catalog.pg_index' then
|
|
29556
29571
|
format('grant select on pg_catalog.pg_index to %I;', current_user)
|
|
29557
29572
|
end
|
|
29573
|
+
when permission_name = 'postgres_ai schema exists' and granted = false then
|
|
29574
|
+
'-- run postgresai prepare-db or create the postgres_ai schema and pg_statistic view manually'
|
|
29575
|
+
when permission_name = 'usage on postgres_ai schema' and granted = false then
|
|
29576
|
+
format('grant usage on schema postgres_ai to %I;', current_user)
|
|
29558
29577
|
when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
|
|
29559
29578
|
'-- create postgres_ai.pg_statistic view (see setup script)'
|
|
29560
29579
|
when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
|
|
@@ -29581,6 +29600,10 @@ function formatPermissionCheckMessages(result) {
|
|
|
29581
29600
|
const warnings = [];
|
|
29582
29601
|
const errors3 = [];
|
|
29583
29602
|
for (const row of result.missingOptional) {
|
|
29603
|
+
if (row.permission_name === "postgres_ai schema exists") {
|
|
29604
|
+
warnings.push("Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them.");
|
|
29605
|
+
continue;
|
|
29606
|
+
}
|
|
29584
29607
|
const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
|
|
29585
29608
|
warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
|
|
29586
29609
|
}
|
|
@@ -29974,15 +29997,6 @@ async function verifyInitSetupViaSupabase(params) {
|
|
|
29974
29997
|
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
29975
29998
|
}
|
|
29976
29999
|
}
|
|
29977
|
-
const explainFnExistsRes = await params.client.query("SELECT oid FROM pg_proc WHERE proname = 'explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')", true);
|
|
29978
|
-
if (explainFnExistsRes.rowCount === 0) {
|
|
29979
|
-
missingRequired.push("function postgres_ai.explain_generic exists");
|
|
29980
|
-
} else {
|
|
29981
|
-
const explainFnRes = await params.client.query(`SELECT has_function_privilege('${escapeLiteral2(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`, true);
|
|
29982
|
-
if (!explainFnRes.rows?.[0]?.ok) {
|
|
29983
|
-
missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
|
|
29984
|
-
}
|
|
29985
|
-
}
|
|
29986
30000
|
const tableDescribeFnExistsRes = await params.client.query("SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')", true);
|
|
29987
30001
|
if (tableDescribeFnExistsRes.rowCount === 0) {
|
|
29988
30002
|
missingRequired.push("function postgres_ai.table_describe exists");
|
|
@@ -32778,12 +32792,70 @@ async function generateF003(client, nodeName) {
|
|
|
32778
32792
|
};
|
|
32779
32793
|
return report;
|
|
32780
32794
|
}
|
|
32795
|
+
function bloatErrorStatus(err) {
|
|
32796
|
+
const error2 = err instanceof Error ? err.message : String(err);
|
|
32797
|
+
const code = typeof err === "object" && err !== null && "code" in err ? String(err.code || "") : "";
|
|
32798
|
+
const normalized = error2.toLowerCase();
|
|
32799
|
+
let reason = "query_error";
|
|
32800
|
+
if (code === "3F000" || normalized.includes('schema "postgres_ai" does not exist')) {
|
|
32801
|
+
reason = "missing_schema";
|
|
32802
|
+
} else if (code === "42P01" || normalized.includes('relation "postgres_ai.pg_statistic" does not exist')) {
|
|
32803
|
+
reason = "missing_view";
|
|
32804
|
+
} else if (code === "42501" || normalized.includes("permission denied")) {
|
|
32805
|
+
reason = "missing_grant";
|
|
32806
|
+
}
|
|
32807
|
+
return { ok: false, reason, error: error2 };
|
|
32808
|
+
}
|
|
32809
|
+
async function getBloatCheckStatus(client) {
|
|
32810
|
+
try {
|
|
32811
|
+
const result = await client.query(`
|
|
32812
|
+
select
|
|
32813
|
+
to_regnamespace('postgres_ai') is not null as schema_exists,
|
|
32814
|
+
case
|
|
32815
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
32816
|
+
else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
|
|
32817
|
+
end as schema_usage,
|
|
32818
|
+
case
|
|
32819
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
32820
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
32821
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
32822
|
+
end as view_exists,
|
|
32823
|
+
case
|
|
32824
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
32825
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
32826
|
+
when to_regclass('postgres_ai.pg_statistic') is null then false
|
|
32827
|
+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'SELECT')
|
|
32828
|
+
end as view_select
|
|
32829
|
+
`);
|
|
32830
|
+
const capability = result.rows[0] || {};
|
|
32831
|
+
if (!capability.schema_exists) {
|
|
32832
|
+
return { ok: false, reason: "missing_schema", error: 'schema "postgres_ai" does not exist' };
|
|
32833
|
+
}
|
|
32834
|
+
if (!capability.schema_usage) {
|
|
32835
|
+
return { ok: false, reason: "missing_grant", error: "permission denied for schema postgres_ai" };
|
|
32836
|
+
}
|
|
32837
|
+
if (!capability.view_exists) {
|
|
32838
|
+
return { ok: false, reason: "missing_view", error: 'relation "postgres_ai.pg_statistic" does not exist' };
|
|
32839
|
+
}
|
|
32840
|
+
if (!capability.view_select) {
|
|
32841
|
+
return { ok: false, reason: "missing_grant", error: "permission denied for relation postgres_ai.pg_statistic" };
|
|
32842
|
+
}
|
|
32843
|
+
return { ok: true, reason: null, error: null };
|
|
32844
|
+
} catch (err) {
|
|
32845
|
+
return bloatErrorStatus(err);
|
|
32846
|
+
}
|
|
32847
|
+
}
|
|
32781
32848
|
async function generateF004(client, nodeName) {
|
|
32782
32849
|
const report = createBaseReport("F004", "Autovacuum: heap bloat (estimated)", nodeName);
|
|
32783
32850
|
const postgresVersion = await getPostgresVersion(client);
|
|
32784
32851
|
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
32785
32852
|
let bloatedTables = [];
|
|
32853
|
+
let status = await getBloatCheckStatus(client);
|
|
32786
32854
|
try {
|
|
32855
|
+
if (!status.ok)
|
|
32856
|
+
throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
32857
|
+
code: status.reason === "missing_schema" ? "3F000" : status.reason === "missing_view" ? "42P01" : status.reason === "missing_grant" ? "42501" : undefined
|
|
32858
|
+
});
|
|
32787
32859
|
const sql = getMetricSql(METRIC_NAMES.F004, pgMajorVersion);
|
|
32788
32860
|
const bloatResult = await client.query(sql);
|
|
32789
32861
|
const vacuumStatsResult = await client.query(`
|
|
@@ -32827,7 +32899,8 @@ async function generateF004(client, nodeName) {
|
|
|
32827
32899
|
};
|
|
32828
32900
|
});
|
|
32829
32901
|
} catch (err) {
|
|
32830
|
-
|
|
32902
|
+
status = bloatErrorStatus(err);
|
|
32903
|
+
const errorMsg = status.error || "Unknown error";
|
|
32831
32904
|
console.error(`[F004] Error estimating table bloat: ${errorMsg}`);
|
|
32832
32905
|
if (errorMsg.includes("postgres_ai.")) {
|
|
32833
32906
|
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
@@ -32837,6 +32910,7 @@ async function generateF004(client, nodeName) {
|
|
|
32837
32910
|
const totalCount = bloatedTables.length;
|
|
32838
32911
|
const totalBloatSizeBytes = bloatedTables.reduce((sum, t) => sum + t.bloat_size, 0);
|
|
32839
32912
|
const dbEntry = {
|
|
32913
|
+
status,
|
|
32840
32914
|
bloated_tables: bloatedTables,
|
|
32841
32915
|
total_count: totalCount,
|
|
32842
32916
|
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
@@ -32855,7 +32929,12 @@ async function generateF005(client, nodeName) {
|
|
|
32855
32929
|
const postgresVersion = await getPostgresVersion(client);
|
|
32856
32930
|
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
32857
32931
|
let bloatedIndexes = [];
|
|
32932
|
+
let status = await getBloatCheckStatus(client);
|
|
32858
32933
|
try {
|
|
32934
|
+
if (!status.ok)
|
|
32935
|
+
throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
32936
|
+
code: status.reason === "missing_schema" ? "3F000" : status.reason === "missing_view" ? "42P01" : status.reason === "missing_grant" ? "42501" : undefined
|
|
32937
|
+
});
|
|
32859
32938
|
const sql = getMetricSql(METRIC_NAMES.F005, pgMajorVersion);
|
|
32860
32939
|
const bloatResult = await client.query(sql);
|
|
32861
32940
|
const vacuumStatsResult = await client.query(`
|
|
@@ -32904,7 +32983,8 @@ async function generateF005(client, nodeName) {
|
|
|
32904
32983
|
};
|
|
32905
32984
|
});
|
|
32906
32985
|
} catch (err) {
|
|
32907
|
-
|
|
32986
|
+
status = bloatErrorStatus(err);
|
|
32987
|
+
const errorMsg = status.error || "Unknown error";
|
|
32908
32988
|
console.error(`[F005] Error estimating index bloat: ${errorMsg}`);
|
|
32909
32989
|
if (errorMsg.includes("postgres_ai.")) {
|
|
32910
32990
|
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
@@ -32914,6 +32994,7 @@ async function generateF005(client, nodeName) {
|
|
|
32914
32994
|
const totalCount = bloatedIndexes.length;
|
|
32915
32995
|
const totalBloatSizeBytes = bloatedIndexes.reduce((sum, idx) => sum + idx.bloat_size, 0);
|
|
32916
32996
|
const dbEntry = {
|
|
32997
|
+
status,
|
|
32917
32998
|
bloated_indexes: bloatedIndexes,
|
|
32918
32999
|
total_count: totalCount,
|
|
32919
33000
|
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
@@ -33586,6 +33667,10 @@ function generateCheckSummary(checkId, report) {
|
|
|
33586
33667
|
return summarizeF001(nodeData);
|
|
33587
33668
|
case "F003":
|
|
33588
33669
|
return summarizeF003(nodeData);
|
|
33670
|
+
case "F004":
|
|
33671
|
+
return summarizeBloat(nodeData, "table");
|
|
33672
|
+
case "F005":
|
|
33673
|
+
return summarizeBloat(nodeData, "index");
|
|
33589
33674
|
case "G001":
|
|
33590
33675
|
return summarizeG001(nodeData);
|
|
33591
33676
|
case "G003":
|
|
@@ -33771,6 +33856,25 @@ function summarizeF003(nodeData) {
|
|
|
33771
33856
|
}
|
|
33772
33857
|
return { status: "warning", message: parts.join(", ") };
|
|
33773
33858
|
}
|
|
33859
|
+
function summarizeBloat(nodeData, kind) {
|
|
33860
|
+
const data = nodeData?.data || {};
|
|
33861
|
+
let totalCount = 0;
|
|
33862
|
+
for (const dbData of Object.values(data)) {
|
|
33863
|
+
const dbEntry = dbData;
|
|
33864
|
+
if (dbEntry?.status?.ok === false) {
|
|
33865
|
+
const reason = String(dbEntry.status.reason || "query_error").replaceAll("_", " ");
|
|
33866
|
+
return { status: "warning", message: `Bloat estimate degraded: ${reason}` };
|
|
33867
|
+
}
|
|
33868
|
+
totalCount += dbEntry?.total_count || 0;
|
|
33869
|
+
}
|
|
33870
|
+
if (totalCount === 0) {
|
|
33871
|
+
return { status: "ok", message: `No bloated ${kind}${kind === "index" ? "es" : "s"} found` };
|
|
33872
|
+
}
|
|
33873
|
+
return {
|
|
33874
|
+
status: "warning",
|
|
33875
|
+
message: `Found ${totalCount} bloated ${kind}${totalCount === 1 ? "" : kind === "index" ? "es" : "s"}`
|
|
33876
|
+
};
|
|
33877
|
+
}
|
|
33774
33878
|
function summarizeG001(nodeData) {
|
|
33775
33879
|
const data = nodeData?.data || {};
|
|
33776
33880
|
const settingsCount = Object.keys(data).length;
|
|
@@ -34127,9 +34231,15 @@ function prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyReque
|
|
|
34127
34231
|
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
34128
34232
|
return null;
|
|
34129
34233
|
}
|
|
34130
|
-
|
|
34131
|
-
|
|
34132
|
-
|
|
34234
|
+
if (opts.markdown) {
|
|
34235
|
+
console.error("Notice: no API key configured \u2014 regular report upload is disabled.");
|
|
34236
|
+
console.error(" The full report JSON will still be sent to the PostgresAI API for markdown conversion.");
|
|
34237
|
+
console.error(" To avoid sending report data, replace --markdown with --no-upload and --json or --output.");
|
|
34238
|
+
} else {
|
|
34239
|
+
console.error("Notice: no API key configured \u2014 results will NOT be uploaded to PostgresAI.");
|
|
34240
|
+
console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
|
|
34241
|
+
console.error(" To run locally without this notice, pass --no-upload.");
|
|
34242
|
+
}
|
|
34133
34243
|
return;
|
|
34134
34244
|
}
|
|
34135
34245
|
const cfg = readConfig();
|
|
@@ -35354,7 +35464,7 @@ program2.command("unprepare-db [conn]").description("remove monitoring setup: dr
|
|
|
35354
35464
|
closeReadline();
|
|
35355
35465
|
}
|
|
35356
35466
|
});
|
|
35357
|
-
program2.command("checkup [checkIdOrConn] [conn]").description("generate health check reports directly from PostgreSQL (express mode)").option("--check-id <id>", `specific check to run (see list below), or ALL`).option("--node-name <name>", "node name for reports", "node-01").option("--output <path>", "output directory for JSON files").option("--upload", "upload JSON results to PostgresAI (requires API key)").option("--no-upload", "disable upload to PostgresAI").option("--project <project>", "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)").option("--json", "output JSON to stdout").option("--markdown", "output markdown
|
|
35467
|
+
program2.command("checkup [checkIdOrConn] [conn]").description("generate health check reports directly from PostgreSQL (express mode)").option("--check-id <id>", `specific check to run (see list below), or ALL`).option("--node-name <name>", "node name for reports", "node-01").option("--output <path>", "output directory for JSON files").option("--upload", "upload JSON results to PostgresAI (requires API key)").option("--no-upload", "disable upload to PostgresAI").option("--project <project>", "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)").option("--json", "output JSON to stdout").option("--markdown", "output markdown via PostgresAI API (transmits the full report JSON)").addHelpText("after", [
|
|
35358
35468
|
"",
|
|
35359
35469
|
"Available checks:",
|
|
35360
35470
|
...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
@@ -35365,7 +35475,7 @@ program2.command("checkup [checkIdOrConn] [conn]").description("generate health
|
|
|
35365
35475
|
" postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
|
|
35366
35476
|
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
35367
35477
|
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
35368
|
-
" postgresai checkup postgresql://user:pass@host:5432/db --
|
|
35478
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --markdown"
|
|
35369
35479
|
].join(`
|
|
35370
35480
|
`)).action(async (checkIdOrConn, connArg, opts, cmd) => {
|
|
35371
35481
|
const checkIdPattern = /^[A-Z]\d{3}$/i;
|
|
@@ -35405,6 +35515,13 @@ Usage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname
|
|
|
35405
35515
|
return;
|
|
35406
35516
|
}
|
|
35407
35517
|
const uploadExplicitlyDisabled = opts.upload === false;
|
|
35518
|
+
if (uploadExplicitlyDisabled && shouldConvertMarkdown) {
|
|
35519
|
+
console.error("Error: --no-upload and --markdown are mutually exclusive");
|
|
35520
|
+
console.error("Markdown conversion is performed by the PostgresAI API and transmits the full report JSON.");
|
|
35521
|
+
console.error("Drop --no-upload to allow transmission, or use --json or --output for local-only output.");
|
|
35522
|
+
process.exitCode = 1;
|
|
35523
|
+
return;
|
|
35524
|
+
}
|
|
35408
35525
|
let shouldUpload = !uploadExplicitlyDisabled;
|
|
35409
35526
|
const outputPath = prepareOutputDirectory(opts.output);
|
|
35410
35527
|
if (outputPath === null) {
|
|
@@ -35587,7 +35704,9 @@ Usage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname
|
|
|
35587
35704
|
console.log(`
|
|
35588
35705
|
For details:`);
|
|
35589
35706
|
console.log(" --json Output JSON");
|
|
35590
|
-
|
|
35707
|
+
if (!uploadExplicitlyDisabled) {
|
|
35708
|
+
console.log(" --markdown Output markdown via PostgresAI API");
|
|
35709
|
+
}
|
|
35591
35710
|
console.log(" --output <dir> Save to directory");
|
|
35592
35711
|
}
|
|
35593
35712
|
} catch (error2) {
|
|
@@ -36802,7 +36921,7 @@ targets.command("test <name>").description("test monitoring target database conn
|
|
|
36802
36921
|
}
|
|
36803
36922
|
});
|
|
36804
36923
|
var auth = program2.command("auth").description("authentication and API key management");
|
|
36805
|
-
|
|
36924
|
+
async function runAuthLogin(opts) {
|
|
36806
36925
|
if (opts.setKey) {
|
|
36807
36926
|
const trimmedKey = opts.setKey.trim();
|
|
36808
36927
|
if (!trimmedKey) {
|
|
@@ -37003,7 +37122,12 @@ Authentication failed: ${message}`);
|
|
|
37003
37122
|
console.error(`Authentication error: ${message}`);
|
|
37004
37123
|
process.exit(1);
|
|
37005
37124
|
}
|
|
37006
|
-
}
|
|
37125
|
+
}
|
|
37126
|
+
function configureLoginCommand(command) {
|
|
37127
|
+
return command.description("authenticate via browser (OAuth) or store API key directly").option("--set-key <key>", "store API key directly without OAuth flow").option("--port <port>", "local callback server port (default: random)", parseInt).option("--debug", "enable debug output");
|
|
37128
|
+
}
|
|
37129
|
+
configureLoginCommand(auth.command("login", { isDefault: true })).action(runAuthLogin);
|
|
37130
|
+
configureLoginCommand(program2.command("login")).action(runAuthLogin);
|
|
37007
37131
|
auth.command("show-key").description("show API key (masked)").action(async () => {
|
|
37008
37132
|
const cfg = readConfig();
|
|
37009
37133
|
if (!cfg.apiKey) {
|
package/dist/sql/06.helpers.sql
CHANGED
|
@@ -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
|
-
|