postgresai 0.16.0-dev.10 → 0.16.0-dev.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -84
- package/bin/postgres-ai.ts +140 -540
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +790 -2282
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -0
- package/lib/joe.ts +333 -391
- package/lib/mcp-server.ts +0 -625
- package/lib/util.ts +145 -29
- package/package.json +1 -1
- 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.test.ts +35 -0
- package/test/joe.cli.test.ts +355 -196
- package/test/joe.test.ts +565 -568
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
- package/test/util.test.ts +116 -0
- package/lib/dblab.ts +0 -449
- package/test/dblab.cli.test.ts +0 -373
- package/test/dblab.test.ts +0 -488
- package/test/e2e/pgai-e2e-smoke.sh +0 -192
package/lib/checkup-summary.ts
CHANGED
|
@@ -41,6 +41,8 @@ export function generateCheckSummary(checkId: string, report: any): CheckSummary
|
|
|
41
41
|
case 'D004': return summarizeD004(nodeData);
|
|
42
42
|
case 'F001': return summarizeF001(nodeData);
|
|
43
43
|
case 'F003': return summarizeF003(nodeData);
|
|
44
|
+
case 'F004': return summarizeBloat(nodeData, 'table');
|
|
45
|
+
case 'F005': return summarizeBloat(nodeData, 'index');
|
|
44
46
|
case 'G001': return summarizeG001(nodeData);
|
|
45
47
|
case 'G003': return summarizeG003(nodeData);
|
|
46
48
|
default:
|
|
@@ -273,6 +275,29 @@ function summarizeF003(nodeData: any): CheckSummary {
|
|
|
273
275
|
return { status: 'warning', message: parts.join(', ') };
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
function summarizeBloat(nodeData: any, kind: 'table' | 'index'): CheckSummary {
|
|
279
|
+
const data = nodeData?.data || {};
|
|
280
|
+
let totalCount = 0;
|
|
281
|
+
|
|
282
|
+
for (const dbData of Object.values(data)) {
|
|
283
|
+
const dbEntry = dbData as any;
|
|
284
|
+
if (dbEntry?.status?.ok === false) {
|
|
285
|
+
const reason = String(dbEntry.status.reason || 'query_error').replaceAll('_', ' ');
|
|
286
|
+
return { status: 'warning', message: `Bloat estimate degraded: ${reason}` };
|
|
287
|
+
}
|
|
288
|
+
totalCount += dbEntry?.total_count || 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (totalCount === 0) {
|
|
292
|
+
return { status: 'ok', message: `No bloated ${kind}${kind === 'index' ? 'es' : 's'} found` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
status: 'warning',
|
|
297
|
+
message: `Found ${totalCount} bloated ${kind}${totalCount === 1 ? '' : kind === 'index' ? 'es' : 's'}`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
276
301
|
function summarizeG001(nodeData: any): CheckSummary {
|
|
277
302
|
const data = nodeData?.data || {};
|
|
278
303
|
const settingsCount = Object.keys(data).length;
|
package/lib/checkup.ts
CHANGED
|
@@ -1498,6 +1498,74 @@ async function generateF003(client: Client, nodeName: string): Promise<Report> {
|
|
|
1498
1498
|
* Uses pg_stats for column statistics to estimate row sizes.
|
|
1499
1499
|
* SQL loaded from config/pgwatch-prometheus/metrics.yml (pg_table_bloat metric).
|
|
1500
1500
|
*/
|
|
1501
|
+
type BloatCheckReason = "missing_schema" | "missing_view" | "missing_grant" | "query_error";
|
|
1502
|
+
|
|
1503
|
+
interface BloatCheckStatus {
|
|
1504
|
+
ok: boolean;
|
|
1505
|
+
reason: BloatCheckReason | null;
|
|
1506
|
+
error: string | null;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function bloatErrorStatus(err: unknown): BloatCheckStatus {
|
|
1510
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
1511
|
+
const code = typeof err === "object" && err !== null && "code" in err
|
|
1512
|
+
? String((err as { code?: unknown }).code || "")
|
|
1513
|
+
: "";
|
|
1514
|
+
const normalized = error.toLowerCase();
|
|
1515
|
+
|
|
1516
|
+
let reason: BloatCheckReason = "query_error";
|
|
1517
|
+
if (code === "3F000" || normalized.includes('schema "postgres_ai" does not exist')) {
|
|
1518
|
+
reason = "missing_schema";
|
|
1519
|
+
} else if (code === "42P01" || normalized.includes('relation "postgres_ai.pg_statistic" does not exist')) {
|
|
1520
|
+
reason = "missing_view";
|
|
1521
|
+
} else if (code === "42501" || normalized.includes("permission denied")) {
|
|
1522
|
+
reason = "missing_grant";
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
return { ok: false, reason, error };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async function getBloatCheckStatus(client: Client): Promise<BloatCheckStatus> {
|
|
1529
|
+
try {
|
|
1530
|
+
const result = await client.query(`
|
|
1531
|
+
select
|
|
1532
|
+
to_regnamespace('postgres_ai') is not null as schema_exists,
|
|
1533
|
+
case
|
|
1534
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
1535
|
+
else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
|
|
1536
|
+
end as schema_usage,
|
|
1537
|
+
case
|
|
1538
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
1539
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
1540
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
1541
|
+
end as view_exists,
|
|
1542
|
+
case
|
|
1543
|
+
when to_regnamespace('postgres_ai') is null then false
|
|
1544
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
1545
|
+
when to_regclass('postgres_ai.pg_statistic') is null then false
|
|
1546
|
+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'SELECT')
|
|
1547
|
+
end as view_select
|
|
1548
|
+
`);
|
|
1549
|
+
const capability = result.rows[0] || {};
|
|
1550
|
+
|
|
1551
|
+
if (!capability.schema_exists) {
|
|
1552
|
+
return { ok: false, reason: "missing_schema", error: 'schema "postgres_ai" does not exist' };
|
|
1553
|
+
}
|
|
1554
|
+
if (!capability.schema_usage) {
|
|
1555
|
+
return { ok: false, reason: "missing_grant", error: "permission denied for schema postgres_ai" };
|
|
1556
|
+
}
|
|
1557
|
+
if (!capability.view_exists) {
|
|
1558
|
+
return { ok: false, reason: "missing_view", error: 'relation "postgres_ai.pg_statistic" does not exist' };
|
|
1559
|
+
}
|
|
1560
|
+
if (!capability.view_select) {
|
|
1561
|
+
return { ok: false, reason: "missing_grant", error: "permission denied for relation postgres_ai.pg_statistic" };
|
|
1562
|
+
}
|
|
1563
|
+
return { ok: true, reason: null, error: null };
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
return bloatErrorStatus(err);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1501
1569
|
async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
1502
1570
|
const report = createBaseReport("F004", "Autovacuum: heap bloat (estimated)", nodeName);
|
|
1503
1571
|
const postgresVersion = await getPostgresVersion(client);
|
|
@@ -1520,8 +1588,15 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
|
1520
1588
|
}
|
|
1521
1589
|
|
|
1522
1590
|
let bloatedTables: BloatedTable[] = [];
|
|
1591
|
+
let status = await getBloatCheckStatus(client);
|
|
1523
1592
|
|
|
1524
1593
|
try {
|
|
1594
|
+
if (!status.ok) throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
1595
|
+
code: status.reason === "missing_schema" ? "3F000"
|
|
1596
|
+
: status.reason === "missing_view" ? "42P01"
|
|
1597
|
+
: status.reason === "missing_grant" ? "42501"
|
|
1598
|
+
: undefined,
|
|
1599
|
+
});
|
|
1525
1600
|
// Get bloat data
|
|
1526
1601
|
const sql = getMetricSql(METRIC_NAMES.F004, pgMajorVersion);
|
|
1527
1602
|
const bloatResult = await client.query(sql);
|
|
@@ -1572,7 +1647,8 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
|
1572
1647
|
};
|
|
1573
1648
|
});
|
|
1574
1649
|
} catch (err) {
|
|
1575
|
-
|
|
1650
|
+
status = bloatErrorStatus(err);
|
|
1651
|
+
const errorMsg = status.error || "Unknown error";
|
|
1576
1652
|
console.error(`[F004] Error estimating table bloat: ${errorMsg}`);
|
|
1577
1653
|
if (errorMsg.includes("postgres_ai.")) {
|
|
1578
1654
|
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
@@ -1587,6 +1663,7 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
|
1587
1663
|
const totalBloatSizeBytes = bloatedTables.reduce((sum, t) => sum + t.bloat_size, 0);
|
|
1588
1664
|
|
|
1589
1665
|
const dbEntry = {
|
|
1666
|
+
status,
|
|
1590
1667
|
bloated_tables: bloatedTables,
|
|
1591
1668
|
total_count: totalCount,
|
|
1592
1669
|
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
@@ -1634,8 +1711,15 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
|
|
|
1634
1711
|
}
|
|
1635
1712
|
|
|
1636
1713
|
let bloatedIndexes: BloatedIndex[] = [];
|
|
1714
|
+
let status = await getBloatCheckStatus(client);
|
|
1637
1715
|
|
|
1638
1716
|
try {
|
|
1717
|
+
if (!status.ok) throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
1718
|
+
code: status.reason === "missing_schema" ? "3F000"
|
|
1719
|
+
: status.reason === "missing_view" ? "42P01"
|
|
1720
|
+
: status.reason === "missing_grant" ? "42501"
|
|
1721
|
+
: undefined,
|
|
1722
|
+
});
|
|
1639
1723
|
// Get bloat data
|
|
1640
1724
|
const sql = getMetricSql(METRIC_NAMES.F005, pgMajorVersion);
|
|
1641
1725
|
const bloatResult = await client.query(sql);
|
|
@@ -1690,7 +1774,8 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
|
|
|
1690
1774
|
};
|
|
1691
1775
|
});
|
|
1692
1776
|
} catch (err) {
|
|
1693
|
-
|
|
1777
|
+
status = bloatErrorStatus(err);
|
|
1778
|
+
const errorMsg = status.error || "Unknown error";
|
|
1694
1779
|
console.error(`[F005] Error estimating index bloat: ${errorMsg}`);
|
|
1695
1780
|
if (errorMsg.includes("postgres_ai.")) {
|
|
1696
1781
|
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
@@ -1705,6 +1790,7 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
|
|
|
1705
1790
|
const totalBloatSizeBytes = bloatedIndexes.reduce((sum, idx) => sum + idx.bloat_size, 0);
|
|
1706
1791
|
|
|
1707
1792
|
const dbEntry = {
|
|
1793
|
+
status,
|
|
1708
1794
|
bloated_indexes: bloatedIndexes,
|
|
1709
1795
|
total_count: totalCount,
|
|
1710
1796
|
total_bloat_size_bytes: totalBloatSizeBytes,
|
package/lib/init.ts
CHANGED
|
@@ -1019,10 +1019,28 @@ export async function checkCurrentUserPermissions(
|
|
|
1019
1019
|
|
|
1020
1020
|
union all
|
|
1021
1021
|
|
|
1022
|
+
select
|
|
1023
|
+
'postgres_ai schema exists' as permission_name,
|
|
1024
|
+
'optional' as status,
|
|
1025
|
+
to_regnamespace('postgres_ai') is not null as granted
|
|
1026
|
+
|
|
1027
|
+
union all
|
|
1028
|
+
|
|
1029
|
+
select
|
|
1030
|
+
'usage on postgres_ai schema' as permission_name,
|
|
1031
|
+
'optional' as status,
|
|
1032
|
+
case
|
|
1033
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
1034
|
+
else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
|
|
1035
|
+
end as granted
|
|
1036
|
+
|
|
1037
|
+
union all
|
|
1038
|
+
|
|
1022
1039
|
select
|
|
1023
1040
|
'postgres_ai.pg_statistic view exists' as permission_name,
|
|
1024
1041
|
'optional' as status,
|
|
1025
1042
|
case
|
|
1043
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
1026
1044
|
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
1027
1045
|
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
1028
1046
|
end as granted
|
|
@@ -1033,6 +1051,7 @@ export async function checkCurrentUserPermissions(
|
|
|
1033
1051
|
'select on postgres_ai.pg_statistic' as permission_name,
|
|
1034
1052
|
'optional' as status,
|
|
1035
1053
|
case
|
|
1054
|
+
when to_regnamespace('postgres_ai') is null then null
|
|
1036
1055
|
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
1037
1056
|
when to_regclass('postgres_ai.pg_statistic') is null then null
|
|
1038
1057
|
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
|
|
@@ -1052,6 +1071,10 @@ export async function checkCurrentUserPermissions(
|
|
|
1052
1071
|
when permission_name like 'select on pg_catalog.pg_index' then
|
|
1053
1072
|
format('grant select on pg_catalog.pg_index to %I;', current_user)
|
|
1054
1073
|
end
|
|
1074
|
+
when permission_name = 'postgres_ai schema exists' and granted = false then
|
|
1075
|
+
'-- run postgresai prepare-db or create the postgres_ai schema and pg_statistic view manually'
|
|
1076
|
+
when permission_name = 'usage on postgres_ai schema' and granted = false then
|
|
1077
|
+
format('grant usage on schema postgres_ai to %I;', current_user)
|
|
1055
1078
|
when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
|
|
1056
1079
|
'-- create postgres_ai.pg_statistic view (see setup script)'
|
|
1057
1080
|
when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
|
|
@@ -1097,6 +1120,12 @@ export function formatPermissionCheckMessages(result: PreflightPermissionResult)
|
|
|
1097
1120
|
const errors: string[] = [];
|
|
1098
1121
|
|
|
1099
1122
|
for (const row of result.missingOptional) {
|
|
1123
|
+
if (row.permission_name === "postgres_ai schema exists") {
|
|
1124
|
+
warnings.push(
|
|
1125
|
+
"Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them."
|
|
1126
|
+
);
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1100
1129
|
const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
|
|
1101
1130
|
warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
|
|
1102
1131
|
}
|