postgresai 0.14.0-dev.70 → 0.14.0-dev.71
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/bin/postgres-ai.ts +27 -2
- package/dist/bin/postgres-ai.js +65 -21
- package/lib/init.ts +76 -19
- package/lib/metrics-embedded.ts +1 -1
- package/package.json +1 -1
- package/test/config-consistency.test.ts +36 -0
- package/test/init.test.ts +155 -0
package/bin/postgres-ai.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { Client } from "pg";
|
|
|
12
12
|
import { startMcpServer } from "../lib/mcp-server";
|
|
13
13
|
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "../lib/issues";
|
|
14
14
|
import { resolveBaseUrls } from "../lib/util";
|
|
15
|
-
import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
|
|
15
|
+
import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
|
|
16
16
|
import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, type PgCompatibleError } from "../lib/supabase";
|
|
17
17
|
import * as pkce from "../lib/pkce";
|
|
18
18
|
import * as authServer from "../lib/auth-server";
|
|
@@ -565,6 +565,7 @@ program
|
|
|
565
565
|
.option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER)
|
|
566
566
|
.option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)")
|
|
567
567
|
.option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false)
|
|
568
|
+
.option("--provider <provider>", "Database provider (e.g., supabase). Affects which steps are executed.")
|
|
568
569
|
.option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false)
|
|
569
570
|
.option("--reset-password", "Reset monitoring role password only (no other changes)", false)
|
|
570
571
|
.option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
|
|
@@ -619,6 +620,10 @@ program
|
|
|
619
620
|
"",
|
|
620
621
|
" Generate a token at: https://supabase.com/dashboard/account/tokens",
|
|
621
622
|
" Find your project ref in: https://supabase.com/dashboard/project/<ref>",
|
|
623
|
+
"",
|
|
624
|
+
"Provider-specific behavior (for direct connections):",
|
|
625
|
+
" --provider supabase Skip role creation (create user in Supabase dashboard)",
|
|
626
|
+
" Skip ALTER USER (restricted by Supabase)",
|
|
622
627
|
].join("\n")
|
|
623
628
|
)
|
|
624
629
|
.action(async (conn: string | undefined, opts: {
|
|
@@ -631,6 +636,7 @@ program
|
|
|
631
636
|
monitoringUser: string;
|
|
632
637
|
password?: string;
|
|
633
638
|
skipOptionalPermissions?: boolean;
|
|
639
|
+
provider?: string;
|
|
634
640
|
verify?: boolean;
|
|
635
641
|
resetPassword?: boolean;
|
|
636
642
|
printSql?: boolean;
|
|
@@ -681,6 +687,12 @@ program
|
|
|
681
687
|
const shouldPrintSql = !!opts.printSql;
|
|
682
688
|
const redactPasswords = (sql: string): string => redactPasswordsInSql(sql);
|
|
683
689
|
|
|
690
|
+
// Validate provider and warn if unknown
|
|
691
|
+
const providerWarning = validateProvider(opts.provider);
|
|
692
|
+
if (providerWarning) {
|
|
693
|
+
console.warn(`⚠ ${providerWarning}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
684
696
|
// Offline mode: allow printing SQL without providing/using an admin connection.
|
|
685
697
|
// Useful for audits/reviews; caller can provide -d/PGDATABASE.
|
|
686
698
|
if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
|
|
@@ -698,11 +710,13 @@ program
|
|
|
698
710
|
monitoringUser: opts.monitoringUser,
|
|
699
711
|
monitoringPassword: monPassword,
|
|
700
712
|
includeOptionalPermissions,
|
|
713
|
+
provider: opts.provider,
|
|
701
714
|
});
|
|
702
715
|
|
|
703
716
|
console.log("\n--- SQL plan (offline; not connected) ---");
|
|
704
717
|
console.log(`-- database: ${database}`);
|
|
705
718
|
console.log(`-- monitoring user: ${opts.monitoringUser}`);
|
|
719
|
+
console.log(`-- provider: ${opts.provider ?? "self-managed"}`);
|
|
706
720
|
console.log(`-- optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
|
|
707
721
|
for (const step of plan.steps) {
|
|
708
722
|
console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
|
|
@@ -1059,6 +1073,7 @@ program
|
|
|
1059
1073
|
database,
|
|
1060
1074
|
monitoringUser: opts.monitoringUser,
|
|
1061
1075
|
includeOptionalPermissions,
|
|
1076
|
+
provider: opts.provider,
|
|
1062
1077
|
});
|
|
1063
1078
|
if (v.ok) {
|
|
1064
1079
|
if (jsonOutput) {
|
|
@@ -1068,11 +1083,12 @@ program
|
|
|
1068
1083
|
action: "verify",
|
|
1069
1084
|
database,
|
|
1070
1085
|
monitoringUser: opts.monitoringUser,
|
|
1086
|
+
provider: opts.provider,
|
|
1071
1087
|
verified: true,
|
|
1072
1088
|
missingOptional: v.missingOptional,
|
|
1073
1089
|
});
|
|
1074
1090
|
} else {
|
|
1075
|
-
console.log(
|
|
1091
|
+
console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
|
|
1076
1092
|
if (v.missingOptional.length > 0) {
|
|
1077
1093
|
console.log("⚠ Optional items missing:");
|
|
1078
1094
|
for (const m of v.missingOptional) console.log(`- ${m}`);
|
|
@@ -1154,12 +1170,21 @@ program
|
|
|
1154
1170
|
monitoringUser: opts.monitoringUser,
|
|
1155
1171
|
monitoringPassword: monPassword,
|
|
1156
1172
|
includeOptionalPermissions,
|
|
1173
|
+
provider: opts.provider,
|
|
1157
1174
|
});
|
|
1158
1175
|
|
|
1176
|
+
// For reset-password, we only want the role step. But if provider skips role creation,
|
|
1177
|
+
// reset-password doesn't make sense - warn the user.
|
|
1159
1178
|
const effectivePlan = opts.resetPassword
|
|
1160
1179
|
? { ...plan, steps: plan.steps.filter((s) => s.name === "01.role") }
|
|
1161
1180
|
: plan;
|
|
1162
1181
|
|
|
1182
|
+
if (opts.resetPassword && effectivePlan.steps.length === 0) {
|
|
1183
|
+
console.error(`✗ --reset-password not supported for provider "${opts.provider}" (role creation is skipped)`);
|
|
1184
|
+
process.exitCode = 1;
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1163
1188
|
if (shouldPrintSql) {
|
|
1164
1189
|
console.log("\n--- SQL plan ---");
|
|
1165
1190
|
for (const step of effectivePlan.steps) {
|
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.71",
|
|
13068
13068
|
description: "postgres_ai CLI",
|
|
13069
13069
|
license: "Apache-2.0",
|
|
13070
13070
|
private: false,
|
|
@@ -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.71";
|
|
15891
15891
|
var package_default2 = {
|
|
15892
15892
|
name: "postgresai",
|
|
15893
15893
|
version,
|
|
@@ -23947,6 +23947,15 @@ import { URL as URL2, fileURLToPath } from "url";
|
|
|
23947
23947
|
import * as fs3 from "fs";
|
|
23948
23948
|
import * as path3 from "path";
|
|
23949
23949
|
var DEFAULT_MONITORING_USER = "postgres_ai_mon";
|
|
23950
|
+
var KNOWN_PROVIDERS = ["self-managed", "supabase"];
|
|
23951
|
+
var SKIP_ROLE_CREATION_PROVIDERS = ["supabase"];
|
|
23952
|
+
var SKIP_ALTER_USER_PROVIDERS = ["supabase"];
|
|
23953
|
+
var SKIP_SEARCH_PATH_CHECK_PROVIDERS = ["supabase"];
|
|
23954
|
+
function validateProvider(provider) {
|
|
23955
|
+
if (!provider || KNOWN_PROVIDERS.includes(provider))
|
|
23956
|
+
return null;
|
|
23957
|
+
return `Unknown provider "${provider}". Known providers: ${KNOWN_PROVIDERS.join(", ")}. Treating as self-managed.`;
|
|
23958
|
+
}
|
|
23950
23959
|
function sslModeToConfig(mode) {
|
|
23951
23960
|
if (mode.toLowerCase() === "disable")
|
|
23952
23961
|
return false;
|
|
@@ -24261,6 +24270,7 @@ async function resolveMonitoringPassword(opts) {
|
|
|
24261
24270
|
async function buildInitPlan(params) {
|
|
24262
24271
|
const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
|
|
24263
24272
|
const database = params.database;
|
|
24273
|
+
const provider = params.provider ?? "self-managed";
|
|
24264
24274
|
const qRole = quoteIdent(monitoringUser);
|
|
24265
24275
|
const qDb = quoteIdent(database);
|
|
24266
24276
|
const qPw = quoteLiteral(params.monitoringPassword);
|
|
@@ -24270,7 +24280,8 @@ async function buildInitPlan(params) {
|
|
|
24270
24280
|
ROLE_IDENT: qRole,
|
|
24271
24281
|
DB_IDENT: qDb
|
|
24272
24282
|
};
|
|
24273
|
-
|
|
24283
|
+
if (!SKIP_ROLE_CREATION_PROVIDERS.includes(provider)) {
|
|
24284
|
+
const roleStmt = `do $$ begin
|
|
24274
24285
|
if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
|
|
24275
24286
|
begin
|
|
24276
24287
|
create user ${qRole} with password ${qPw};
|
|
@@ -24280,11 +24291,23 @@ async function buildInitPlan(params) {
|
|
|
24280
24291
|
end if;
|
|
24281
24292
|
alter user ${qRole} with password ${qPw};
|
|
24282
24293
|
end $$;`;
|
|
24283
|
-
|
|
24284
|
-
|
|
24294
|
+
const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
|
|
24295
|
+
steps.push({ name: "01.role", sql: roleSql });
|
|
24296
|
+
}
|
|
24297
|
+
let permissionsSql = applyTemplate(loadSqlTemplate("02.permissions.sql"), vars);
|
|
24298
|
+
if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
|
|
24299
|
+
permissionsSql = permissionsSql.split(`
|
|
24300
|
+
`).filter((line) => {
|
|
24301
|
+
const trimmed = line.trim();
|
|
24302
|
+
if (trimmed.startsWith("--") || trimmed === "")
|
|
24303
|
+
return true;
|
|
24304
|
+
return !/^\s*alter\s+user\s+/i.test(line);
|
|
24305
|
+
}).join(`
|
|
24306
|
+
`);
|
|
24307
|
+
}
|
|
24285
24308
|
steps.push({
|
|
24286
24309
|
name: "02.permissions",
|
|
24287
|
-
sql:
|
|
24310
|
+
sql: permissionsSql
|
|
24288
24311
|
});
|
|
24289
24312
|
steps.push({
|
|
24290
24313
|
name: "05.helpers",
|
|
@@ -24372,6 +24395,7 @@ async function verifyInitSetup(params) {
|
|
|
24372
24395
|
const missingOptional = [];
|
|
24373
24396
|
const role = params.monitoringUser;
|
|
24374
24397
|
const db = params.database;
|
|
24398
|
+
const provider = params.provider ?? "self-managed";
|
|
24375
24399
|
const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
24376
24400
|
const roleExists = (roleRes.rowCount ?? 0) > 0;
|
|
24377
24401
|
if (!roleExists) {
|
|
@@ -24407,15 +24431,17 @@ async function verifyInitSetup(params) {
|
|
|
24407
24431
|
if (!schemaUsageRes.rows?.[0]?.ok) {
|
|
24408
24432
|
missingRequired.push("USAGE on schema public");
|
|
24409
24433
|
}
|
|
24410
|
-
|
|
24411
|
-
|
|
24412
|
-
|
|
24413
|
-
|
|
24414
|
-
|
|
24415
|
-
|
|
24416
|
-
|
|
24417
|
-
|
|
24418
|
-
|
|
24434
|
+
if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
|
|
24435
|
+
const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
24436
|
+
const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
|
|
24437
|
+
const spLine = Array.isArray(rolconfig) ? rolconfig.find((v) => String(v).startsWith("search_path=")) : undefined;
|
|
24438
|
+
if (typeof spLine !== "string" || !spLine) {
|
|
24439
|
+
missingRequired.push("role search_path is set");
|
|
24440
|
+
} else {
|
|
24441
|
+
const sp = spLine.toLowerCase();
|
|
24442
|
+
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
24443
|
+
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
24444
|
+
}
|
|
24419
24445
|
}
|
|
24420
24446
|
}
|
|
24421
24447
|
const explainFnRes = await params.client.query("select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok", [role]);
|
|
@@ -27134,7 +27160,7 @@ program2.command("set-default-project <project>").description("store default pro
|
|
|
27134
27160
|
writeConfig({ defaultProject: value });
|
|
27135
27161
|
console.log(`Default project saved: ${value}`);
|
|
27136
27162
|
});
|
|
27137
|
-
program2.command("prepare-db [conn]").description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)").option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)").option("-h, --host <host>", "PostgreSQL host (psql-like)").option("-p, --port <port>", "PostgreSQL port (psql-like)").option("-U, --username <username>", "PostgreSQL user (psql-like)").option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)").option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)").option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER).option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)").option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false).option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false).option("--reset-password", "Reset monitoring role password only (no other changes)", false).option("--print-sql", "Print SQL plan and exit (no changes applied)", false).option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false).option("--supabase", "Use Supabase Management API instead of direct PostgreSQL connection", false).option("--supabase-access-token <token>", "Supabase Management API access token (or SUPABASE_ACCESS_TOKEN env)").option("--supabase-project-ref <ref>", "Supabase project reference (or SUPABASE_PROJECT_REF env)").option("--json", "Output result as JSON (machine-readable)", false).addHelpText("after", [
|
|
27163
|
+
program2.command("prepare-db [conn]").description("prepare database for monitoring: create monitoring user, required view(s), and grant permissions (idempotent)").option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)").option("-h, --host <host>", "PostgreSQL host (psql-like)").option("-p, --port <port>", "PostgreSQL port (psql-like)").option("-U, --username <username>", "PostgreSQL user (psql-like)").option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)").option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)").option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER).option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)").option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false).option("--provider <provider>", "Database provider (e.g., supabase). Affects which steps are executed.").option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false).option("--reset-password", "Reset monitoring role password only (no other changes)", false).option("--print-sql", "Print SQL plan and exit (no changes applied)", false).option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false).option("--supabase", "Use Supabase Management API instead of direct PostgreSQL connection", false).option("--supabase-access-token <token>", "Supabase Management API access token (or SUPABASE_ACCESS_TOKEN env)").option("--supabase-project-ref <ref>", "Supabase project reference (or SUPABASE_PROJECT_REF env)").option("--json", "Output result as JSON (machine-readable)", false).addHelpText("after", [
|
|
27138
27164
|
"",
|
|
27139
27165
|
"Examples:",
|
|
27140
27166
|
" postgresai prepare-db postgresql://admin@host:5432/dbname",
|
|
@@ -27177,7 +27203,11 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
|
|
|
27177
27203
|
" SUPABASE_ACCESS_TOKEN=... postgresai prepare-db --supabase --supabase-project-ref <ref>",
|
|
27178
27204
|
"",
|
|
27179
27205
|
" Generate a token at: https://supabase.com/dashboard/account/tokens",
|
|
27180
|
-
" Find your project ref in: https://supabase.com/dashboard/project/<ref>"
|
|
27206
|
+
" Find your project ref in: https://supabase.com/dashboard/project/<ref>",
|
|
27207
|
+
"",
|
|
27208
|
+
"Provider-specific behavior (for direct connections):",
|
|
27209
|
+
" --provider supabase Skip role creation (create user in Supabase dashboard)",
|
|
27210
|
+
" Skip ALTER USER (restricted by Supabase)"
|
|
27181
27211
|
].join(`
|
|
27182
27212
|
`)).action(async (conn, opts, cmd) => {
|
|
27183
27213
|
const jsonOutput = opts.json;
|
|
@@ -27216,6 +27246,10 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
|
|
|
27216
27246
|
}
|
|
27217
27247
|
const shouldPrintSql = !!opts.printSql;
|
|
27218
27248
|
const redactPasswords = (sql) => redactPasswordsInSql(sql);
|
|
27249
|
+
const providerWarning = validateProvider(opts.provider);
|
|
27250
|
+
if (providerWarning) {
|
|
27251
|
+
console.warn(`\u26A0 ${providerWarning}`);
|
|
27252
|
+
}
|
|
27219
27253
|
if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
|
|
27220
27254
|
if (shouldPrintSql) {
|
|
27221
27255
|
const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
|
|
@@ -27225,12 +27259,14 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
|
|
|
27225
27259
|
database,
|
|
27226
27260
|
monitoringUser: opts.monitoringUser,
|
|
27227
27261
|
monitoringPassword: monPassword,
|
|
27228
|
-
includeOptionalPermissions: includeOptionalPermissions2
|
|
27262
|
+
includeOptionalPermissions: includeOptionalPermissions2,
|
|
27263
|
+
provider: opts.provider
|
|
27229
27264
|
});
|
|
27230
27265
|
console.log(`
|
|
27231
27266
|
--- SQL plan (offline; not connected) ---`);
|
|
27232
27267
|
console.log(`-- database: ${database}`);
|
|
27233
27268
|
console.log(`-- monitoring user: ${opts.monitoringUser}`);
|
|
27269
|
+
console.log(`-- provider: ${opts.provider ?? "self-managed"}`);
|
|
27234
27270
|
console.log(`-- optional permissions: ${includeOptionalPermissions2 ? "enabled" : "skipped"}`);
|
|
27235
27271
|
for (const step of plan.steps) {
|
|
27236
27272
|
console.log(`
|
|
@@ -27550,7 +27586,8 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
|
|
|
27550
27586
|
client,
|
|
27551
27587
|
database,
|
|
27552
27588
|
monitoringUser: opts.monitoringUser,
|
|
27553
|
-
includeOptionalPermissions
|
|
27589
|
+
includeOptionalPermissions,
|
|
27590
|
+
provider: opts.provider
|
|
27554
27591
|
});
|
|
27555
27592
|
if (v.ok) {
|
|
27556
27593
|
if (jsonOutput) {
|
|
@@ -27560,11 +27597,12 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
|
|
|
27560
27597
|
action: "verify",
|
|
27561
27598
|
database,
|
|
27562
27599
|
monitoringUser: opts.monitoringUser,
|
|
27600
|
+
provider: opts.provider,
|
|
27563
27601
|
verified: true,
|
|
27564
27602
|
missingOptional: v.missingOptional
|
|
27565
27603
|
});
|
|
27566
27604
|
} else {
|
|
27567
|
-
console.log(
|
|
27605
|
+
console.log(`\u2713 prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
|
|
27568
27606
|
if (v.missingOptional.length > 0) {
|
|
27569
27607
|
console.log("\u26A0 Optional items missing:");
|
|
27570
27608
|
for (const m of v.missingOptional)
|
|
@@ -27642,9 +27680,15 @@ program2.command("prepare-db [conn]").description("prepare database for monitori
|
|
|
27642
27680
|
database,
|
|
27643
27681
|
monitoringUser: opts.monitoringUser,
|
|
27644
27682
|
monitoringPassword: monPassword,
|
|
27645
|
-
includeOptionalPermissions
|
|
27683
|
+
includeOptionalPermissions,
|
|
27684
|
+
provider: opts.provider
|
|
27646
27685
|
});
|
|
27647
27686
|
const effectivePlan = opts.resetPassword ? { ...plan, steps: plan.steps.filter((s) => s.name === "01.role") } : plan;
|
|
27687
|
+
if (opts.resetPassword && effectivePlan.steps.length === 0) {
|
|
27688
|
+
console.error(`\u2717 --reset-password not supported for provider "${opts.provider}" (role creation is skipped)`);
|
|
27689
|
+
process.exitCode = 1;
|
|
27690
|
+
return;
|
|
27691
|
+
}
|
|
27648
27692
|
if (shouldPrintSql) {
|
|
27649
27693
|
console.log(`
|
|
27650
27694
|
--- SQL plan ---`);
|
package/lib/init.ts
CHANGED
|
@@ -7,6 +7,32 @@ import * as path from "path";
|
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_MONITORING_USER = "postgres_ai_mon";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Database provider type. Affects which prepare-db steps are executed.
|
|
12
|
+
* Known providers have specific behavior adjustments; unknown providers use default behavior.
|
|
13
|
+
* TODO: Consider auto-detecting provider from connection string or server version string.
|
|
14
|
+
* TODO: Consider making this more flexible via a config that specifies which steps/checks to skip.
|
|
15
|
+
*/
|
|
16
|
+
export type DbProvider = string;
|
|
17
|
+
|
|
18
|
+
/** Known providers with special handling. Unknown providers are treated as self-managed. */
|
|
19
|
+
export const KNOWN_PROVIDERS = ["self-managed", "supabase"] as const;
|
|
20
|
+
|
|
21
|
+
/** Providers where we skip role creation (users managed externally). */
|
|
22
|
+
const SKIP_ROLE_CREATION_PROVIDERS = ["supabase"];
|
|
23
|
+
|
|
24
|
+
/** Providers where we skip ALTER USER statements (restricted by provider). */
|
|
25
|
+
const SKIP_ALTER_USER_PROVIDERS = ["supabase"];
|
|
26
|
+
|
|
27
|
+
/** Providers where we skip search_path verification (not set via ALTER USER). */
|
|
28
|
+
const SKIP_SEARCH_PATH_CHECK_PROVIDERS = ["supabase"];
|
|
29
|
+
|
|
30
|
+
/** Check if a provider is known and return a warning message if not. */
|
|
31
|
+
export function validateProvider(provider: string | undefined): string | null {
|
|
32
|
+
if (!provider || KNOWN_PROVIDERS.includes(provider as any)) return null;
|
|
33
|
+
return `Unknown provider "${provider}". Known providers: ${KNOWN_PROVIDERS.join(", ")}. Treating as self-managed.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
export type PgClientConfig = {
|
|
11
37
|
connectionString?: string;
|
|
12
38
|
host?: string;
|
|
@@ -458,10 +484,13 @@ export async function buildInitPlan(params: {
|
|
|
458
484
|
monitoringUser?: string;
|
|
459
485
|
monitoringPassword: string;
|
|
460
486
|
includeOptionalPermissions: boolean;
|
|
487
|
+
/** Provider type. Affects which steps are included. Defaults to "self-managed". */
|
|
488
|
+
provider?: DbProvider;
|
|
461
489
|
}): Promise<InitPlan> {
|
|
462
490
|
// NOTE: kept async for API stability / potential future async template loading.
|
|
463
491
|
const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
|
|
464
492
|
const database = params.database;
|
|
493
|
+
const provider = params.provider ?? "self-managed";
|
|
465
494
|
|
|
466
495
|
const qRole = quoteIdent(monitoringUser);
|
|
467
496
|
const qDb = quoteIdent(database);
|
|
@@ -475,12 +504,15 @@ export async function buildInitPlan(params: {
|
|
|
475
504
|
DB_IDENT: qDb,
|
|
476
505
|
};
|
|
477
506
|
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
507
|
+
// Some providers (e.g., Supabase) manage users externally - skip role creation.
|
|
508
|
+
// TODO: Make this more flexible by allowing users to specify which steps to skip via config.
|
|
509
|
+
if (!SKIP_ROLE_CREATION_PROVIDERS.includes(provider)) {
|
|
510
|
+
// Role creation/update is done in one template file.
|
|
511
|
+
// Always use a single DO block to avoid race conditions between "role exists?" checks and CREATE USER.
|
|
512
|
+
// We:
|
|
513
|
+
// - create role if missing (and handle duplicate_object in case another session created it concurrently),
|
|
514
|
+
// - then ALTER ROLE to ensure the password is set to the desired value.
|
|
515
|
+
const roleStmt = `do $$ begin
|
|
484
516
|
if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
|
|
485
517
|
begin
|
|
486
518
|
create user ${qRole} with password ${qPw};
|
|
@@ -491,12 +523,30 @@ export async function buildInitPlan(params: {
|
|
|
491
523
|
alter user ${qRole} with password ${qPw};
|
|
492
524
|
end $$;`;
|
|
493
525
|
|
|
494
|
-
|
|
495
|
-
|
|
526
|
+
const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
|
|
527
|
+
steps.push({ name: "01.role", sql: roleSql });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let permissionsSql = applyTemplate(loadSqlTemplate("02.permissions.sql"), vars);
|
|
531
|
+
|
|
532
|
+
// Some providers restrict ALTER USER - remove those statements.
|
|
533
|
+
// TODO: Make this more flexible by allowing users to specify which statements to skip via config.
|
|
534
|
+
if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
|
|
535
|
+
permissionsSql = permissionsSql
|
|
536
|
+
.split("\n")
|
|
537
|
+
.filter((line) => {
|
|
538
|
+
const trimmed = line.trim();
|
|
539
|
+
// Keep comments and empty lines
|
|
540
|
+
if (trimmed.startsWith("--") || trimmed === "") return true;
|
|
541
|
+
// Filter out ALTER USER statements (case-insensitive, flexible whitespace)
|
|
542
|
+
return !/^\s*alter\s+user\s+/i.test(line);
|
|
543
|
+
})
|
|
544
|
+
.join("\n");
|
|
545
|
+
}
|
|
496
546
|
|
|
497
547
|
steps.push({
|
|
498
548
|
name: "02.permissions",
|
|
499
|
-
sql:
|
|
549
|
+
sql: permissionsSql,
|
|
500
550
|
});
|
|
501
551
|
|
|
502
552
|
// Helper functions (SECURITY DEFINER) for plan analysis and table info
|
|
@@ -612,6 +662,8 @@ export async function verifyInitSetup(params: {
|
|
|
612
662
|
database: string;
|
|
613
663
|
monitoringUser: string;
|
|
614
664
|
includeOptionalPermissions: boolean;
|
|
665
|
+
/** Provider type. Affects which checks are performed. */
|
|
666
|
+
provider?: DbProvider;
|
|
615
667
|
}): Promise<VerifyInitResult> {
|
|
616
668
|
// Use a repeatable-read snapshot so all checks see a consistent view.
|
|
617
669
|
await params.client.query("begin isolation level repeatable read;");
|
|
@@ -621,6 +673,7 @@ export async function verifyInitSetup(params: {
|
|
|
621
673
|
|
|
622
674
|
const role = params.monitoringUser;
|
|
623
675
|
const db = params.database;
|
|
676
|
+
const provider = params.provider ?? "self-managed";
|
|
624
677
|
|
|
625
678
|
const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
626
679
|
const roleExists = (roleRes.rowCount ?? 0) > 0;
|
|
@@ -684,16 +737,20 @@ export async function verifyInitSetup(params: {
|
|
|
684
737
|
missingRequired.push("USAGE on schema public");
|
|
685
738
|
}
|
|
686
739
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
740
|
+
// Some providers don't allow setting search_path via ALTER USER - skip this check.
|
|
741
|
+
// TODO: Make this more flexible by allowing users to specify which checks to skip via config.
|
|
742
|
+
if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
|
|
743
|
+
const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
744
|
+
const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
|
|
745
|
+
const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
|
|
746
|
+
if (typeof spLine !== "string" || !spLine) {
|
|
747
|
+
missingRequired.push("role search_path is set");
|
|
748
|
+
} else {
|
|
749
|
+
// We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
|
|
750
|
+
const sp = spLine.toLowerCase();
|
|
751
|
+
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
752
|
+
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
753
|
+
}
|
|
697
754
|
}
|
|
698
755
|
}
|
|
699
756
|
|
package/lib/metrics-embedded.ts
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that config files are consistent with what the CLI expects.
|
|
3
|
+
* Catches schema mismatches like pg_statistic in wrong schema.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect } from "bun:test";
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { resolve } from "path";
|
|
8
|
+
|
|
9
|
+
const configDir = resolve(import.meta.dir, "../../config");
|
|
10
|
+
|
|
11
|
+
describe("Config consistency", () => {
|
|
12
|
+
test("target-db/init.sql creates pg_statistic in postgres_ai schema", () => {
|
|
13
|
+
const initSql = readFileSync(resolve(configDir, "target-db/init.sql"), "utf8");
|
|
14
|
+
|
|
15
|
+
// Must create postgres_ai schema
|
|
16
|
+
expect(initSql).toMatch(/create\s+schema\s+if\s+not\s+exists\s+postgres_ai/i);
|
|
17
|
+
|
|
18
|
+
// Must create view in postgres_ai schema, not public
|
|
19
|
+
expect(initSql).toMatch(/create\s+or\s+replace\s+view\s+postgres_ai\.pg_statistic/i);
|
|
20
|
+
expect(initSql).not.toMatch(/create\s+or\s+replace\s+view\s+public\.pg_statistic/i);
|
|
21
|
+
|
|
22
|
+
// Must grant on postgres_ai.pg_statistic
|
|
23
|
+
expect(initSql).toMatch(/grant\s+select\s+on\s+postgres_ai\.pg_statistic/i);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("pgwatch metrics.yml uses postgres_ai.pg_statistic", () => {
|
|
27
|
+
const metricsYml = readFileSync(
|
|
28
|
+
resolve(configDir, "pgwatch-prometheus/metrics.yml"),
|
|
29
|
+
"utf8"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Should reference postgres_ai.pg_statistic, not public.pg_statistic
|
|
33
|
+
expect(metricsYml).not.toMatch(/public\.pg_statistic/);
|
|
34
|
+
expect(metricsYml).toMatch(/postgres_ai\.pg_statistic/);
|
|
35
|
+
});
|
|
36
|
+
});
|
package/test/init.test.ts
CHANGED
|
@@ -152,6 +152,42 @@ describe("init module", () => {
|
|
|
152
152
|
expect(plan.steps.some((s: { optional?: boolean }) => s.optional)).toBe(true);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
test("buildInitPlan skips role creation for supabase provider", async () => {
|
|
156
|
+
const plan = await init.buildInitPlan({
|
|
157
|
+
database: "mydb",
|
|
158
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
159
|
+
monitoringPassword: "pw",
|
|
160
|
+
includeOptionalPermissions: false,
|
|
161
|
+
provider: "supabase",
|
|
162
|
+
});
|
|
163
|
+
expect(plan.steps.some((s) => s.name === "01.role")).toBe(false);
|
|
164
|
+
expect(plan.steps.some((s) => s.name === "02.permissions")).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("buildInitPlan removes ALTER USER for supabase provider", async () => {
|
|
168
|
+
const plan = await init.buildInitPlan({
|
|
169
|
+
database: "mydb",
|
|
170
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
171
|
+
monitoringPassword: "pw",
|
|
172
|
+
includeOptionalPermissions: false,
|
|
173
|
+
provider: "supabase",
|
|
174
|
+
});
|
|
175
|
+
const permStep = plan.steps.find((s) => s.name === "02.permissions");
|
|
176
|
+
expect(permStep).toBeDefined();
|
|
177
|
+
expect(permStep!.sql.toLowerCase()).not.toMatch(/alter user/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("buildInitPlan includes role creation for unknown provider", async () => {
|
|
181
|
+
const plan = await init.buildInitPlan({
|
|
182
|
+
database: "mydb",
|
|
183
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
184
|
+
monitoringPassword: "pw",
|
|
185
|
+
includeOptionalPermissions: false,
|
|
186
|
+
provider: "some-custom-provider",
|
|
187
|
+
});
|
|
188
|
+
expect(plan.steps.some((s) => s.name === "01.role")).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
155
191
|
test("resolveAdminConnection accepts positional URI", () => {
|
|
156
192
|
const r = init.resolveAdminConnection({ conn: "postgresql://u:p@h:5432/d" });
|
|
157
193
|
expect(r.clientConfig.connectionString).toBeTruthy();
|
|
@@ -290,6 +326,91 @@ describe("init module", () => {
|
|
|
290
326
|
expect(calls[calls.length - 1].toLowerCase()).toBe("rollback;");
|
|
291
327
|
});
|
|
292
328
|
|
|
329
|
+
test("verifyInitSetup skips search_path check for supabase provider", async () => {
|
|
330
|
+
const calls: string[] = [];
|
|
331
|
+
const client = {
|
|
332
|
+
query: async (sql: string, params?: any) => {
|
|
333
|
+
calls.push(String(sql));
|
|
334
|
+
|
|
335
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
336
|
+
return { rowCount: 1, rows: [] };
|
|
337
|
+
}
|
|
338
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
339
|
+
return { rowCount: 1, rows: [] };
|
|
340
|
+
}
|
|
341
|
+
// Return empty rolconfig - would fail without provider=supabase
|
|
342
|
+
if (String(sql).includes("select rolconfig")) {
|
|
343
|
+
return { rowCount: 1, rows: [{ rolconfig: null }] };
|
|
344
|
+
}
|
|
345
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
346
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
347
|
+
}
|
|
348
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
349
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
350
|
+
}
|
|
351
|
+
if (String(sql).includes("pg_has_role")) {
|
|
352
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
353
|
+
}
|
|
354
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
355
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
356
|
+
}
|
|
357
|
+
if (String(sql).includes("to_regclass")) {
|
|
358
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
359
|
+
}
|
|
360
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
361
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
362
|
+
}
|
|
363
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
364
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// With provider=supabase, should pass even without search_path
|
|
372
|
+
const r = await init.verifyInitSetup({
|
|
373
|
+
client: client as any,
|
|
374
|
+
database: "mydb",
|
|
375
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
376
|
+
includeOptionalPermissions: false,
|
|
377
|
+
provider: "supabase",
|
|
378
|
+
});
|
|
379
|
+
expect(r.ok).toBe(true);
|
|
380
|
+
expect(r.missingRequired.length).toBe(0);
|
|
381
|
+
// Should not have queried for rolconfig since we skip search_path check
|
|
382
|
+
expect(calls.some((c) => c.includes("select rolconfig"))).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("buildInitPlan preserves comments when filtering ALTER USER", async () => {
|
|
386
|
+
const plan = await init.buildInitPlan({
|
|
387
|
+
database: "mydb",
|
|
388
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
389
|
+
monitoringPassword: "pw",
|
|
390
|
+
includeOptionalPermissions: false,
|
|
391
|
+
provider: "supabase",
|
|
392
|
+
});
|
|
393
|
+
const permStep = plan.steps.find((s) => s.name === "02.permissions");
|
|
394
|
+
expect(permStep).toBeDefined();
|
|
395
|
+
// Should have removed ALTER USER but kept comments
|
|
396
|
+
expect(permStep!.sql.toLowerCase()).not.toMatch(/^\s*alter\s+user/m);
|
|
397
|
+
// Should still have comment lines
|
|
398
|
+
expect(permStep!.sql).toMatch(/^--/m);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("validateProvider returns null for known providers", () => {
|
|
402
|
+
expect(init.validateProvider(undefined)).toBe(null);
|
|
403
|
+
expect(init.validateProvider("self-managed")).toBe(null);
|
|
404
|
+
expect(init.validateProvider("supabase")).toBe(null);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("validateProvider returns warning for unknown providers", () => {
|
|
408
|
+
const warning = init.validateProvider("unknown-provider");
|
|
409
|
+
expect(warning).not.toBe(null);
|
|
410
|
+
expect(warning).toMatch(/Unknown provider/);
|
|
411
|
+
expect(warning).toMatch(/unknown-provider/);
|
|
412
|
+
});
|
|
413
|
+
|
|
293
414
|
test("redactPasswordsInSql redacts password literals with embedded quotes", async () => {
|
|
294
415
|
const plan = await init.buildInitPlan({
|
|
295
416
|
database: "mydb",
|
|
@@ -319,6 +440,40 @@ describe("CLI commands", () => {
|
|
|
319
440
|
expect(r.stdout).toMatch(new RegExp(`grant connect on database "mydb" to "${DEFAULT_MONITORING_USER}"`, "i"));
|
|
320
441
|
});
|
|
321
442
|
|
|
443
|
+
test("cli: prepare-db --print-sql with --provider supabase skips role step", () => {
|
|
444
|
+
const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "supabase"]);
|
|
445
|
+
expect(r.status).toBe(0);
|
|
446
|
+
expect(r.stdout).toMatch(/provider: supabase/);
|
|
447
|
+
// Should not have 01.role step
|
|
448
|
+
expect(r.stdout).not.toMatch(/-- 01\.role/);
|
|
449
|
+
// Should have 02.permissions step
|
|
450
|
+
expect(r.stdout).toMatch(/-- 02\.permissions/);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("cli: prepare-db warns about unknown provider", () => {
|
|
454
|
+
const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "unknown-cloud"]);
|
|
455
|
+
expect(r.status).toBe(0);
|
|
456
|
+
// Should warn about unknown provider
|
|
457
|
+
expect(r.stderr).toMatch(/Unknown provider.*unknown-cloud/);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("cli: prepare-db --reset-password with supabase provider would have no role step", async () => {
|
|
461
|
+
// When using supabase provider, the role creation step is skipped.
|
|
462
|
+
// This means --reset-password (which only runs 01.role) would have no steps.
|
|
463
|
+
// The CLI should error in this case. We test the underlying plan logic here.
|
|
464
|
+
const plan = await (await import("../lib/init")).buildInitPlan({
|
|
465
|
+
database: "mydb",
|
|
466
|
+
monitoringUser: "mon",
|
|
467
|
+
monitoringPassword: "pw",
|
|
468
|
+
includeOptionalPermissions: false,
|
|
469
|
+
provider: "supabase",
|
|
470
|
+
});
|
|
471
|
+
// Simulate what --reset-password does: filter to only 01.role step
|
|
472
|
+
const resetPasswordSteps = plan.steps.filter((s) => s.name === "01.role");
|
|
473
|
+
// For supabase, this should be empty (role creation is skipped)
|
|
474
|
+
expect(resetPasswordSteps.length).toBe(0);
|
|
475
|
+
});
|
|
476
|
+
|
|
322
477
|
test("pgai wrapper forwards to postgresai CLI", () => {
|
|
323
478
|
const r = runPgai(["--help"]);
|
|
324
479
|
expect(r.status).toBe(0);
|