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.
@@ -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("✓ prepare-db verify: OK");
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) {
@@ -13064,7 +13064,7 @@ var {
13064
13064
  // package.json
13065
13065
  var package_default = {
13066
13066
  name: "postgresai",
13067
- version: "0.14.0-dev.70",
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.70";
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
- const roleStmt = `do $$ begin
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
- const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
24284
- steps.push({ name: "01.role", sql: roleSql });
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: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars)
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
- const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
24411
- const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
24412
- const spLine = Array.isArray(rolconfig) ? rolconfig.find((v) => String(v).startsWith("search_path=")) : undefined;
24413
- if (typeof spLine !== "string" || !spLine) {
24414
- missingRequired.push("role search_path is set");
24415
- } else {
24416
- const sp = spLine.toLowerCase();
24417
- if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
24418
- missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
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("\u2713 prepare-db verify: OK");
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
- // Role creation/update is done in one template file.
479
- // Always use a single DO block to avoid race conditions between "role exists?" checks and CREATE USER.
480
- // We:
481
- // - create role if missing (and handle duplicate_object in case another session created it concurrently),
482
- // - then ALTER ROLE to ensure the password is set to the desired value.
483
- const roleStmt = `do $$ begin
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
- const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
495
- steps.push({ name: "01.role", sql: roleSql });
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: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
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
- const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
688
- const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
689
- const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
690
- if (typeof spLine !== "string" || !spLine) {
691
- missingRequired.push("role search_path is set");
692
- } else {
693
- // We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
694
- const sp = spLine.toLowerCase();
695
- if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
696
- missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
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
 
@@ -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: 2026-01-08T20:01:57.541Z
3
+ // Generated at: 2026-01-09T12:41:56.884Z
4
4
 
5
5
  /**
6
6
  * Metric definition from metrics.yml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-dev.70",
3
+ "version": "0.14.0-dev.71",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -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);