postgresai 0.14.0-dev.8 → 0.14.0-dev.80

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.
Files changed (96) hide show
  1. package/README.md +161 -61
  2. package/bin/postgres-ai.ts +2596 -428
  3. package/bun.lock +258 -0
  4. package/bunfig.toml +20 -0
  5. package/dist/bin/postgres-ai.js +31218 -1575
  6. package/dist/sql/01.role.sql +16 -0
  7. package/dist/sql/02.extensions.sql +8 -0
  8. package/dist/sql/03.permissions.sql +38 -0
  9. package/dist/sql/04.optional_rds.sql +6 -0
  10. package/dist/sql/05.optional_self_managed.sql +8 -0
  11. package/dist/sql/06.helpers.sql +439 -0
  12. package/dist/sql/sql/01.role.sql +16 -0
  13. package/dist/sql/sql/02.extensions.sql +8 -0
  14. package/dist/sql/sql/03.permissions.sql +38 -0
  15. package/dist/sql/sql/04.optional_rds.sql +6 -0
  16. package/dist/sql/sql/05.optional_self_managed.sql +8 -0
  17. package/dist/sql/sql/06.helpers.sql +439 -0
  18. package/dist/sql/sql/uninit/01.helpers.sql +5 -0
  19. package/dist/sql/sql/uninit/02.permissions.sql +30 -0
  20. package/dist/sql/sql/uninit/03.role.sql +27 -0
  21. package/dist/sql/uninit/01.helpers.sql +5 -0
  22. package/dist/sql/uninit/02.permissions.sql +30 -0
  23. package/dist/sql/uninit/03.role.sql +27 -0
  24. package/lib/auth-server.ts +124 -106
  25. package/lib/checkup-api.ts +386 -0
  26. package/lib/checkup-dictionary.ts +113 -0
  27. package/lib/checkup.ts +1435 -0
  28. package/lib/config.ts +6 -3
  29. package/lib/init.ts +655 -189
  30. package/lib/issues.ts +848 -193
  31. package/lib/mcp-server.ts +391 -91
  32. package/lib/metrics-loader.ts +127 -0
  33. package/lib/supabase.ts +824 -0
  34. package/lib/util.ts +61 -0
  35. package/package.json +22 -10
  36. package/packages/postgres-ai/README.md +26 -0
  37. package/packages/postgres-ai/bin/postgres-ai.js +27 -0
  38. package/packages/postgres-ai/package.json +27 -0
  39. package/scripts/embed-checkup-dictionary.ts +106 -0
  40. package/scripts/embed-metrics.ts +154 -0
  41. package/sql/01.role.sql +16 -0
  42. package/sql/02.extensions.sql +8 -0
  43. package/sql/03.permissions.sql +38 -0
  44. package/sql/04.optional_rds.sql +6 -0
  45. package/sql/05.optional_self_managed.sql +8 -0
  46. package/sql/06.helpers.sql +439 -0
  47. package/sql/uninit/01.helpers.sql +5 -0
  48. package/sql/uninit/02.permissions.sql +30 -0
  49. package/sql/uninit/03.role.sql +27 -0
  50. package/test/auth.test.ts +258 -0
  51. package/test/checkup.integration.test.ts +321 -0
  52. package/test/checkup.test.ts +1116 -0
  53. package/test/config-consistency.test.ts +36 -0
  54. package/test/init.integration.test.ts +508 -0
  55. package/test/init.test.ts +916 -0
  56. package/test/issues.cli.test.ts +538 -0
  57. package/test/issues.test.ts +456 -0
  58. package/test/mcp-server.test.ts +1527 -0
  59. package/test/schema-validation.test.ts +81 -0
  60. package/test/supabase.test.ts +568 -0
  61. package/test/test-utils.ts +128 -0
  62. package/tsconfig.json +12 -20
  63. package/dist/bin/postgres-ai.d.ts +0 -3
  64. package/dist/bin/postgres-ai.d.ts.map +0 -1
  65. package/dist/bin/postgres-ai.js.map +0 -1
  66. package/dist/lib/auth-server.d.ts +0 -31
  67. package/dist/lib/auth-server.d.ts.map +0 -1
  68. package/dist/lib/auth-server.js +0 -263
  69. package/dist/lib/auth-server.js.map +0 -1
  70. package/dist/lib/config.d.ts +0 -45
  71. package/dist/lib/config.d.ts.map +0 -1
  72. package/dist/lib/config.js +0 -181
  73. package/dist/lib/config.js.map +0 -1
  74. package/dist/lib/init.d.ts +0 -64
  75. package/dist/lib/init.d.ts.map +0 -1
  76. package/dist/lib/init.js +0 -399
  77. package/dist/lib/init.js.map +0 -1
  78. package/dist/lib/issues.d.ts +0 -75
  79. package/dist/lib/issues.d.ts.map +0 -1
  80. package/dist/lib/issues.js +0 -336
  81. package/dist/lib/issues.js.map +0 -1
  82. package/dist/lib/mcp-server.d.ts +0 -9
  83. package/dist/lib/mcp-server.d.ts.map +0 -1
  84. package/dist/lib/mcp-server.js +0 -168
  85. package/dist/lib/mcp-server.js.map +0 -1
  86. package/dist/lib/pkce.d.ts +0 -32
  87. package/dist/lib/pkce.d.ts.map +0 -1
  88. package/dist/lib/pkce.js +0 -101
  89. package/dist/lib/pkce.js.map +0 -1
  90. package/dist/lib/util.d.ts +0 -27
  91. package/dist/lib/util.d.ts.map +0 -1
  92. package/dist/lib/util.js +0 -46
  93. package/dist/lib/util.js.map +0 -1
  94. package/dist/package.json +0 -46
  95. package/test/init.integration.test.cjs +0 -269
  96. package/test/init.test.cjs +0 -76
package/lib/init.ts CHANGED
@@ -1,7 +1,37 @@
1
- import * as readline from "readline";
2
1
  import { randomBytes } from "crypto";
3
- import { URL } from "url";
2
+ import { URL, fileURLToPath } from "url";
3
+ import type { ConnectionOptions as TlsConnectionOptions } from "tls";
4
4
  import type { Client as PgClient } from "pg";
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+
8
+ export const DEFAULT_MONITORING_USER = "postgres_ai_mon";
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
+ }
5
35
 
6
36
  export type PgClientConfig = {
7
37
  connectionString?: string;
@@ -10,14 +40,138 @@ export type PgClientConfig = {
10
40
  user?: string;
11
41
  password?: string;
12
42
  database?: string;
13
- ssl?: any;
43
+ ssl?: boolean | TlsConnectionOptions;
14
44
  };
15
45
 
46
+ /**
47
+ * Convert PostgreSQL sslmode to node-postgres ssl config.
48
+ */
49
+ function sslModeToConfig(mode: string): boolean | TlsConnectionOptions {
50
+ if (mode.toLowerCase() === "disable") return false;
51
+ if (mode.toLowerCase() === "verify-full" || mode.toLowerCase() === "verify-ca") return true;
52
+ // For require/prefer/allow: encrypt without certificate verification
53
+ return { rejectUnauthorized: false };
54
+ }
55
+
56
+ /** Extract sslmode from a PostgreSQL connection URI. */
57
+ function extractSslModeFromUri(uri: string): string | undefined {
58
+ try {
59
+ return new URL(uri).searchParams.get("sslmode") ?? undefined;
60
+ } catch {
61
+ return uri.match(/[?&]sslmode=([^&]+)/i)?.[1];
62
+ }
63
+ }
64
+
65
+ /** Remove sslmode parameter from a PostgreSQL connection URI. */
66
+ function stripSslModeFromUri(uri: string): string {
67
+ try {
68
+ const u = new URL(uri);
69
+ u.searchParams.delete("sslmode");
70
+ return u.toString();
71
+ } catch {
72
+ // Fallback regex for malformed URIs
73
+ return uri
74
+ .replace(/[?&]sslmode=[^&]*/gi, "")
75
+ .replace(/\?&/, "?")
76
+ .replace(/\?$/, "");
77
+ }
78
+ }
79
+
16
80
  export type AdminConnection = {
17
81
  clientConfig: PgClientConfig;
18
82
  display: string;
83
+ /** True if SSL fallback is enabled (try SSL first, fall back to non-SSL on failure). */
84
+ sslFallbackEnabled?: boolean;
19
85
  };
20
86
 
87
+ /**
88
+ * Check if an error indicates SSL negotiation failed and fallback to non-SSL should be attempted.
89
+ * This mimics libpq's sslmode=prefer behavior.
90
+ *
91
+ * IMPORTANT: This should NOT match certificate errors (expired, invalid, self-signed)
92
+ * as those are real errors the user needs to fix, not negotiation failures.
93
+ */
94
+ function isSslNegotiationError(err: unknown): boolean {
95
+ if (!err || typeof err !== "object") return false;
96
+ const e = err as any;
97
+ const msg = typeof e.message === "string" ? e.message.toLowerCase() : "";
98
+ const code = typeof e.code === "string" ? e.code : "";
99
+
100
+ // Specific patterns that indicate server doesn't support SSL (should fallback)
101
+ const fallbackPatterns = [
102
+ "the server does not support ssl",
103
+ "ssl off",
104
+ "server does not support ssl connections",
105
+ ];
106
+
107
+ for (const pattern of fallbackPatterns) {
108
+ if (msg.includes(pattern)) return true;
109
+ }
110
+
111
+ // PostgreSQL error code 08P01 (protocol violation) during initial connection
112
+ // often indicates SSL negotiation mismatch, but only if the message suggests it
113
+ if (code === "08P01" && (msg.includes("ssl") || msg.includes("unsupported"))) {
114
+ return true;
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ /**
121
+ * Connect to PostgreSQL with sslmode=prefer-like behavior.
122
+ * If sslFallbackEnabled is true, tries SSL first, then falls back to non-SSL on failure.
123
+ */
124
+ export async function connectWithSslFallback(
125
+ ClientClass: new (config: PgClientConfig) => PgClient,
126
+ adminConn: AdminConnection,
127
+ verbose?: boolean
128
+ ): Promise<{ client: PgClient; usedSsl: boolean }> {
129
+ const tryConnect = async (config: PgClientConfig): Promise<PgClient> => {
130
+ const client = new ClientClass(config);
131
+ await client.connect();
132
+ return client;
133
+ };
134
+
135
+ // If SSL was explicitly set or no SSL configured, just try once
136
+ if (!adminConn.sslFallbackEnabled) {
137
+ const client = await tryConnect(adminConn.clientConfig);
138
+ return { client, usedSsl: !!adminConn.clientConfig.ssl };
139
+ }
140
+
141
+ // sslmode=prefer behavior: try SSL first, fallback to non-SSL
142
+ try {
143
+ const client = await tryConnect(adminConn.clientConfig);
144
+ return { client, usedSsl: true };
145
+ } catch (sslErr) {
146
+ if (!isSslNegotiationError(sslErr)) {
147
+ // Not an SSL error, don't retry
148
+ throw sslErr;
149
+ }
150
+
151
+ if (verbose) {
152
+ console.log("SSL connection failed, retrying without SSL...");
153
+ }
154
+
155
+ // Retry without SSL
156
+ const noSslConfig: PgClientConfig = { ...adminConn.clientConfig, ssl: false };
157
+ try {
158
+ const client = await tryConnect(noSslConfig);
159
+ return { client, usedSsl: false };
160
+ } catch (noSslErr) {
161
+ // If non-SSL also fails, check if it's "SSL required" - throw that instead
162
+ if (isSslNegotiationError(noSslErr)) {
163
+ const msg = (noSslErr as any)?.message || "";
164
+ if (msg.toLowerCase().includes("ssl") && msg.toLowerCase().includes("required")) {
165
+ // Server requires SSL but SSL attempt failed - throw original SSL error
166
+ throw sslErr;
167
+ }
168
+ }
169
+ // Throw the non-SSL error (it's more relevant since SSL attempt also failed)
170
+ throw noSslErr;
171
+ }
172
+ }
173
+ }
174
+
21
175
  export type InitStep = {
22
176
  name: string;
23
177
  sql: string;
@@ -31,11 +185,64 @@ export type InitPlan = {
31
185
  steps: InitStep[];
32
186
  };
33
187
 
188
+ function sqlDir(): string {
189
+ // Handle both development and production paths
190
+ // Development: lib/init.ts -> ../sql
191
+ // Production (bundled): dist/bin/postgres-ai.js -> ../sql (copied during build)
192
+ //
193
+ // IMPORTANT: Use import.meta.url instead of __dirname because bundlers (bun/esbuild)
194
+ // bake in __dirname at build time, while import.meta.url resolves at runtime.
195
+ const currentFile = fileURLToPath(import.meta.url);
196
+ const currentDir = path.dirname(currentFile);
197
+
198
+ const candidates = [
199
+ path.resolve(currentDir, "..", "sql"), // bundled: dist/bin -> dist/sql
200
+ path.resolve(currentDir, "..", "..", "sql"), // dev from lib: lib -> ../sql
201
+ ];
202
+
203
+ for (const candidate of candidates) {
204
+ if (fs.existsSync(candidate)) {
205
+ return candidate;
206
+ }
207
+ }
208
+ throw new Error(`SQL directory not found. Searched: ${candidates.join(", ")}`);
209
+ }
210
+
211
+ function loadSqlTemplate(filename: string): string {
212
+ const p = path.join(sqlDir(), filename);
213
+ return fs.readFileSync(p, "utf8");
214
+ }
215
+
216
+ function applyTemplate(sql: string, vars: Record<string, string>): string {
217
+ return sql.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => {
218
+ const v = vars[key];
219
+ if (v === undefined) throw new Error(`Missing SQL template var: ${key}`);
220
+ return v;
221
+ });
222
+ }
223
+
34
224
  function quoteIdent(ident: string): string {
35
225
  // Always quote. Escape embedded quotes by doubling.
226
+ if (ident.includes("\0")) {
227
+ throw new Error("Identifier cannot contain null bytes");
228
+ }
36
229
  return `"${ident.replace(/"/g, "\"\"")}"`;
37
230
  }
38
231
 
232
+ function quoteLiteral(value: string): string {
233
+ // Single-quote and escape embedded quotes by doubling.
234
+ // This is used where Postgres grammar requires a literal (e.g., CREATE/ALTER ROLE PASSWORD).
235
+ if (value.includes("\0")) {
236
+ throw new Error("Literal cannot contain null bytes");
237
+ }
238
+ return `'${value.replace(/'/g, "''")}'`;
239
+ }
240
+
241
+ export function redactPasswordsInSql(sql: string): string {
242
+ // Replace PASSWORD '<literal>' (handles doubled quotes inside).
243
+ return sql.replace(/password\s+'(?:''|[^'])*'/gi, "password '<redacted>'");
244
+ }
245
+
39
246
  export function maskConnectionString(dbUrl: string): string {
40
247
  // Hide password if present (postgresql://user:pass@host/db).
41
248
  try {
@@ -99,6 +306,7 @@ function tokenizeConninfo(input: string): string[] {
99
306
  export function parseLibpqConninfo(input: string): PgClientConfig {
100
307
  const tokens = tokenizeConninfo(input);
101
308
  const cfg: PgClientConfig = {};
309
+ let sslmode: string | undefined;
102
310
 
103
311
  for (const t of tokens) {
104
312
  const eq = t.indexOf("=");
@@ -127,12 +335,20 @@ export function parseLibpqConninfo(input: string): PgClientConfig {
127
335
  case "database":
128
336
  cfg.database = val;
129
337
  break;
130
- // ignore everything else (sslmode, options, application_name, etc.)
338
+ case "sslmode":
339
+ sslmode = val;
340
+ break;
341
+ // ignore everything else (options, application_name, etc.)
131
342
  default:
132
343
  break;
133
344
  }
134
345
  }
135
346
 
347
+ // Apply SSL configuration based on sslmode
348
+ if (sslmode) {
349
+ cfg.ssl = sslModeToConfig(sslmode);
350
+ }
351
+
136
352
  return cfg;
137
353
  }
138
354
 
@@ -159,8 +375,12 @@ export function resolveAdminConnection(opts: {
159
375
  const conn = (opts.conn || "").trim();
160
376
  const dbUrlFlag = (opts.dbUrlFlag || "").trim();
161
377
 
162
- const hasPsqlParts =
163
- !!(opts.host || opts.port || opts.username || opts.dbname || opts.adminPassword || opts.envPassword);
378
+ // Resolve explicit SSL setting from environment (undefined = auto-detect)
379
+ const explicitSsl = process.env.PGSSLMODE;
380
+
381
+ // NOTE: passwords alone (PGPASSWORD / --admin-password) do NOT constitute a connection.
382
+ // We require at least some connection addressing (host/port/user/db) if no positional arg / --db-url is provided.
383
+ const hasConnDetails = !!(opts.host || opts.port || opts.username || opts.dbname);
164
384
 
165
385
  if (conn && dbUrlFlag) {
166
386
  throw new Error("Provide either positional connection string or --db-url, not both");
@@ -169,28 +389,45 @@ export function resolveAdminConnection(opts: {
169
389
  if (conn || dbUrlFlag) {
170
390
  const v = conn || dbUrlFlag;
171
391
  if (isLikelyUri(v)) {
172
- return { clientConfig: { connectionString: v }, display: maskConnectionString(v) };
392
+ const urlSslMode = extractSslModeFromUri(v);
393
+ const effectiveSslMode = explicitSsl || urlSslMode;
394
+ // SSL priority: PGSSLMODE env > URL param > auto (sslmode=prefer behavior)
395
+ const sslConfig = effectiveSslMode
396
+ ? sslModeToConfig(effectiveSslMode)
397
+ : { rejectUnauthorized: false }; // Default: try SSL (with fallback)
398
+ // Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
399
+ const shouldFallback = !effectiveSslMode ||
400
+ effectiveSslMode.toLowerCase() === "prefer" ||
401
+ effectiveSslMode.toLowerCase() === "allow";
402
+ // Strip sslmode from URI so pg uses our ssl config object instead
403
+ const cleanUri = stripSslModeFromUri(v);
404
+ return {
405
+ clientConfig: { connectionString: cleanUri, ssl: sslConfig },
406
+ display: maskConnectionString(v),
407
+ sslFallbackEnabled: shouldFallback,
408
+ };
173
409
  }
174
410
  // libpq conninfo (dbname=... host=...)
175
411
  const cfg = parseLibpqConninfo(v);
176
412
  if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
177
- return { clientConfig: cfg, display: describePgConfig(cfg) };
413
+ const cfgHadSsl = cfg.ssl !== undefined;
414
+ if (cfg.ssl === undefined) {
415
+ if (explicitSsl) cfg.ssl = sslModeToConfig(explicitSsl);
416
+ else cfg.ssl = { rejectUnauthorized: false }; // Default: try SSL (with fallback)
417
+ }
418
+ // Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
419
+ const shouldFallback = (!explicitSsl && !cfgHadSsl) ||
420
+ (!!explicitSsl && (explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow"));
421
+ return {
422
+ clientConfig: cfg,
423
+ display: describePgConfig(cfg),
424
+ sslFallbackEnabled: shouldFallback,
425
+ };
178
426
  }
179
427
 
180
- if (!hasPsqlParts) {
181
- throw new Error(
182
- [
183
- "Connection is required.",
184
- "",
185
- "Examples:",
186
- " postgresai init postgresql://admin@host:5432/dbname",
187
- " postgresai init \"dbname=dbname host=host user=admin\"",
188
- " postgresai init -h host -p 5432 -U admin -d dbname",
189
- "",
190
- "Admin password:",
191
- " --admin-password <password> (or set PGPASSWORD)",
192
- ].join("\n")
193
- );
428
+ if (!hasConnDetails) {
429
+ // Keep this message short: the CLI prints full help (including examples) on this error.
430
+ throw new Error("Connection is required.");
194
431
  }
195
432
 
196
433
  const cfg: PgClientConfig = {};
@@ -206,84 +443,30 @@ export function resolveAdminConnection(opts: {
206
443
  if (opts.dbname) cfg.database = opts.dbname;
207
444
  if (opts.adminPassword) cfg.password = opts.adminPassword;
208
445
  if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
209
- return { clientConfig: cfg, display: describePgConfig(cfg) };
210
- }
211
-
212
- export async function promptHidden(prompt: string): Promise<string> {
213
- // Implement our own hidden input reader so:
214
- // - prompt text is visible
215
- // - only user input is masked
216
- // - we don't rely on non-public readline internals
217
- if (!process.stdin.isTTY) {
218
- throw new Error("Cannot prompt for password in non-interactive mode");
446
+ if (explicitSsl) {
447
+ cfg.ssl = sslModeToConfig(explicitSsl);
448
+ // Enable fallback for explicit "prefer"/"allow"
449
+ const shouldFallback = explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow";
450
+ return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: shouldFallback };
219
451
  }
220
-
221
- const stdin = process.stdin;
222
- const stdout = process.stdout as NodeJS.WriteStream;
223
-
224
- stdout.write(prompt);
225
-
226
- return await new Promise<string>((resolve, reject) => {
227
- let value = "";
228
-
229
- const cleanup = () => {
230
- try {
231
- stdin.setRawMode(false);
232
- } catch {
233
- // ignore
234
- }
235
- stdin.removeListener("keypress", onKeypress);
236
- };
237
-
238
- const onKeypress = (str: string, key: any) => {
239
- if (key?.ctrl && key?.name === "c") {
240
- stdout.write("\n");
241
- cleanup();
242
- reject(new Error("Cancelled"));
243
- return;
244
- }
245
-
246
- if (key?.name === "return" || key?.name === "enter") {
247
- stdout.write("\n");
248
- cleanup();
249
- resolve(value);
250
- return;
251
- }
252
-
253
- if (key?.name === "backspace") {
254
- if (value.length > 0) {
255
- value = value.slice(0, -1);
256
- // Erase one mask char.
257
- stdout.write("\b \b");
258
- }
259
- return;
260
- }
261
-
262
- // Ignore other control keys.
263
- if (key?.ctrl || key?.meta) return;
264
-
265
- if (typeof str === "string" && str.length > 0) {
266
- value += str;
267
- stdout.write("*");
268
- }
269
- };
270
-
271
- readline.emitKeypressEvents(stdin);
272
- stdin.setRawMode(true);
273
- stdin.on("keypress", onKeypress);
274
- stdin.resume();
275
- });
452
+ // Default: try SSL with fallback (sslmode=prefer behavior)
453
+ cfg.ssl = { rejectUnauthorized: false };
454
+ return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: true };
276
455
  }
277
456
 
278
457
  function generateMonitoringPassword(): string {
279
- // URL-safe and easy to copy/paste; length ~32 chars.
280
- return randomBytes(24).toString("base64url");
458
+ // URL-safe and easy to copy/paste; 24 bytes => 32 base64url chars (no padding).
459
+ // Note: randomBytes() throws on failure; we add a tiny sanity check for unexpected output.
460
+ const password = randomBytes(24).toString("base64url");
461
+ if (password.length < 30) {
462
+ throw new Error("Password generation failed: unexpected output length");
463
+ }
464
+ return password;
281
465
  }
282
466
 
283
467
  export async function resolveMonitoringPassword(opts: {
284
468
  passwordFlag?: string;
285
469
  passwordEnv?: string;
286
- prompt?: (prompt: string) => Promise<string>;
287
470
  monitoringUser: string;
288
471
  }): Promise<{ password: string; generated: boolean }> {
289
472
  const fromFlag = (opts.passwordFlag || "").trim();
@@ -301,106 +484,93 @@ export async function buildInitPlan(params: {
301
484
  monitoringUser?: string;
302
485
  monitoringPassword: string;
303
486
  includeOptionalPermissions: boolean;
304
- roleExists?: boolean;
487
+ /** Provider type. Affects which steps are included. Defaults to "self-managed". */
488
+ provider?: DbProvider;
305
489
  }): Promise<InitPlan> {
306
- const monitoringUser = params.monitoringUser || "postgres_ai_mon";
490
+ // NOTE: kept async for API stability / potential future async template loading.
491
+ const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
307
492
  const database = params.database;
493
+ const provider = params.provider ?? "self-managed";
308
494
 
309
495
  const qRole = quoteIdent(monitoringUser);
310
496
  const qDb = quoteIdent(database);
497
+ const qPw = quoteLiteral(params.monitoringPassword);
498
+ const qRoleNameLit = quoteLiteral(monitoringUser);
311
499
 
312
500
  const steps: InitStep[] = [];
313
501
 
314
- // Role creation/update is done in two alternative steps. Caller decides by checking role existence.
315
- if (params.roleExists === false) {
316
- steps.push({
317
- name: "create monitoring user",
318
- sql: `create user ${qRole} with password $1;`,
319
- params: [params.monitoringPassword],
320
- });
321
- } else if (params.roleExists === true) {
322
- steps.push({
323
- name: "update monitoring user password",
324
- sql: `alter user ${qRole} with password $1;`,
325
- params: [params.monitoringPassword],
326
- });
327
- } else {
328
- // Unknown: caller will rebuild after probing role existence.
502
+ const vars: Record<string, string> = {
503
+ ROLE_IDENT: qRole,
504
+ DB_IDENT: qDb,
505
+ };
506
+
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
516
+ if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
517
+ begin
518
+ create user ${qRole} with password ${qPw};
519
+ exception when duplicate_object then
520
+ null;
521
+ end;
522
+ end if;
523
+ alter user ${qRole} with password ${qPw};
524
+ end $$;`;
525
+
526
+ const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
527
+ steps.push({ name: "01.role", sql: roleSql });
329
528
  }
330
529
 
331
- steps.push(
332
- {
333
- name: "grant connect on database",
334
- sql: `grant connect on database ${qDb} to ${qRole};`,
335
- },
336
- {
337
- name: "grant pg_monitor",
338
- sql: `grant pg_monitor to ${qRole};`,
339
- },
340
- {
341
- name: "grant select on pg_index",
342
- sql: `grant select on pg_catalog.pg_index to ${qRole};`,
343
- },
344
- {
345
- name: "create or replace public.pg_statistic view",
346
- sql: `create or replace view public.pg_statistic as
347
- select
348
- n.nspname as schemaname,
349
- c.relname as tablename,
350
- a.attname,
351
- s.stanullfrac as null_frac,
352
- s.stawidth as avg_width,
353
- false as inherited
354
- from pg_catalog.pg_statistic s
355
- join pg_catalog.pg_class c on c.oid = s.starelid
356
- join pg_catalog.pg_namespace n on n.oid = c.relnamespace
357
- join pg_catalog.pg_attribute a on a.attrelid = s.starelid and a.attnum = s.staattnum
358
- where a.attnum > 0 and not a.attisdropped;`,
359
- },
360
- {
361
- name: "grant select on public.pg_statistic",
362
- sql: `grant select on public.pg_statistic to ${qRole};`,
363
- },
364
- {
365
- name: "ensure access to public schema (for hardened clusters)",
366
- sql: `grant usage on schema public to ${qRole};`,
367
- },
368
- {
369
- name: "set monitoring user search_path",
370
- sql: `alter user ${qRole} set search_path = "$user", public, pg_catalog;`,
371
- }
372
- );
530
+ // Extensions should be created before permissions (so we can grant permissions on them)
531
+ steps.push({
532
+ name: "02.extensions",
533
+ sql: loadSqlTemplate("02.extensions.sql"),
534
+ });
535
+
536
+ let permissionsSql = applyTemplate(loadSqlTemplate("03.permissions.sql"), vars);
537
+
538
+ // Some providers restrict ALTER USER - remove those statements.
539
+ // TODO: Make this more flexible by allowing users to specify which statements to skip via config.
540
+ if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
541
+ permissionsSql = permissionsSql
542
+ .split("\n")
543
+ .filter((line) => {
544
+ const trimmed = line.trim();
545
+ // Keep comments and empty lines
546
+ if (trimmed.startsWith("--") || trimmed === "") return true;
547
+ // Filter out ALTER USER statements (case-insensitive, flexible whitespace)
548
+ return !/^\s*alter\s+user\s+/i.test(line);
549
+ })
550
+ .join("\n");
551
+ }
552
+
553
+ steps.push({
554
+ name: "03.permissions",
555
+ sql: permissionsSql,
556
+ });
557
+
558
+ // Helper functions (SECURITY DEFINER) for plan analysis and table info
559
+ steps.push({
560
+ name: "06.helpers",
561
+ sql: applyTemplate(loadSqlTemplate("06.helpers.sql"), vars),
562
+ });
373
563
 
374
564
  if (params.includeOptionalPermissions) {
375
565
  steps.push(
376
566
  {
377
- name: "create rds_tools extension (optional)",
378
- sql: "create extension if not exists rds_tools;",
567
+ name: "04.optional_rds",
568
+ sql: applyTemplate(loadSqlTemplate("04.optional_rds.sql"), vars),
379
569
  optional: true,
380
570
  },
381
571
  {
382
- name: "grant rds_tools.pg_ls_multixactdir() (optional)",
383
- sql: `grant execute on function rds_tools.pg_ls_multixactdir() to ${qRole};`,
384
- optional: true,
385
- },
386
- {
387
- name: "grant pg_stat_file(text) (optional)",
388
- sql: `grant execute on function pg_catalog.pg_stat_file(text) to ${qRole};`,
389
- optional: true,
390
- },
391
- {
392
- name: "grant pg_stat_file(text, boolean) (optional)",
393
- sql: `grant execute on function pg_catalog.pg_stat_file(text, boolean) to ${qRole};`,
394
- optional: true,
395
- },
396
- {
397
- name: "grant pg_ls_dir(text) (optional)",
398
- sql: `grant execute on function pg_catalog.pg_ls_dir(text) to ${qRole};`,
399
- optional: true,
400
- },
401
- {
402
- name: "grant pg_ls_dir(text, boolean, boolean) (optional)",
403
- sql: `grant execute on function pg_catalog.pg_ls_dir(text, boolean, boolean) to ${qRole};`,
572
+ name: "05.optional_self_managed",
573
+ sql: applyTemplate(loadSqlTemplate("05.optional_self_managed.sql"), vars),
404
574
  optional: true,
405
575
  }
406
576
  );
@@ -417,28 +587,66 @@ export async function applyInitPlan(params: {
417
587
  const applied: string[] = [];
418
588
  const skippedOptional: string[] = [];
419
589
 
420
- // Apply non-optional steps in a single transaction.
421
- await params.client.query("begin;");
422
- try {
423
- for (const step of params.plan.steps.filter((s) => !s.optional)) {
590
+ // Helper to wrap a step execution in begin/commit
591
+ const executeStep = async (step: InitStep): Promise<void> => {
592
+ await params.client.query("begin;");
593
+ try {
594
+ await params.client.query(step.sql, step.params as any);
595
+ await params.client.query("commit;");
596
+ } catch (e) {
597
+ // Rollback errors should never mask the original failure.
424
598
  try {
425
- await params.client.query(step.sql, step.params as any);
426
- applied.push(step.name);
427
- } catch (e) {
428
- const msg = e instanceof Error ? e.message : String(e);
429
- throw new Error(`Failed at step "${step.name}": ${msg}`);
599
+ await params.client.query("rollback;");
600
+ } catch {
601
+ // ignore
602
+ }
603
+ throw e;
604
+ }
605
+ };
606
+
607
+ // Apply non-optional steps, each in its own transaction
608
+ for (const step of params.plan.steps.filter((s) => !s.optional)) {
609
+ try {
610
+ await executeStep(step);
611
+ applied.push(step.name);
612
+ } catch (e) {
613
+ const msg = e instanceof Error ? e.message : String(e);
614
+ const errAny = e as any;
615
+ const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
616
+ // Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
617
+ const pgErrorFields = [
618
+ "code",
619
+ "detail",
620
+ "hint",
621
+ "position",
622
+ "internalPosition",
623
+ "internalQuery",
624
+ "where",
625
+ "schema",
626
+ "table",
627
+ "column",
628
+ "dataType",
629
+ "constraint",
630
+ "file",
631
+ "line",
632
+ "routine",
633
+ ] as const;
634
+ if (errAny && typeof errAny === "object") {
635
+ for (const field of pgErrorFields) {
636
+ if (errAny[field] !== undefined) wrapped[field] = errAny[field];
637
+ }
638
+ }
639
+ if (e instanceof Error && e.stack) {
640
+ wrapped.stack = e.stack;
430
641
  }
642
+ throw wrapped;
431
643
  }
432
- await params.client.query("commit;");
433
- } catch (e) {
434
- await params.client.query("rollback;");
435
- throw e;
436
644
  }
437
645
 
438
- // Apply optional steps outside of the transaction so a failure doesn't abort everything.
646
+ // Apply optional steps, each in its own transaction (failure doesn't abort)
439
647
  for (const step of params.plan.steps.filter((s) => s.optional)) {
440
648
  try {
441
- await params.client.query(step.sql, step.params as any);
649
+ await executeStep(step);
442
650
  applied.push(step.name);
443
651
  } catch {
444
652
  skippedOptional.push(step.name);
@@ -449,4 +657,262 @@ export async function applyInitPlan(params: {
449
657
  return { applied, skippedOptional };
450
658
  }
451
659
 
660
+ export type VerifyInitResult = {
661
+ ok: boolean;
662
+ missingRequired: string[];
663
+ missingOptional: string[];
664
+ };
665
+
666
+ export type UninitPlan = {
667
+ monitoringUser: string;
668
+ database: string;
669
+ steps: InitStep[];
670
+ /** If true, also drop the monitoring role. If false, only revoke permissions. */
671
+ dropRole: boolean;
672
+ };
673
+
674
+ export async function buildUninitPlan(params: {
675
+ database: string;
676
+ monitoringUser?: string;
677
+ /** If true, drop the role entirely. If false, only revoke permissions/drop objects. */
678
+ dropRole?: boolean;
679
+ /** Provider type. Affects which steps are included. Defaults to "self-managed". */
680
+ provider?: DbProvider;
681
+ }): Promise<UninitPlan> {
682
+ const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
683
+ const database = params.database;
684
+ const provider = params.provider ?? "self-managed";
685
+ const dropRole = params.dropRole ?? true;
686
+
687
+ const qRole = quoteIdent(monitoringUser);
688
+ const qDb = quoteIdent(database);
689
+ const qRoleLiteral = quoteLiteral(monitoringUser);
690
+
691
+ const steps: InitStep[] = [];
692
+
693
+ const vars: Record<string, string> = {
694
+ ROLE_IDENT: qRole,
695
+ DB_IDENT: qDb,
696
+ ROLE_LITERAL: qRoleLiteral,
697
+ };
698
+
699
+ // Step 1: Drop helper functions
700
+ steps.push({
701
+ name: "01.drop_helpers",
702
+ sql: applyTemplate(loadSqlTemplate("uninit/01.helpers.sql"), vars),
703
+ });
704
+
705
+ // Step 2: Drop view, revoke permissions, drop schema
706
+ steps.push({
707
+ name: "02.revoke_permissions",
708
+ sql: applyTemplate(loadSqlTemplate("uninit/02.permissions.sql"), vars),
709
+ });
710
+
711
+ // Step 3: Drop the role (only if requested and provider allows it)
712
+ if (dropRole && !SKIP_ROLE_CREATION_PROVIDERS.includes(provider)) {
713
+ steps.push({
714
+ name: "03.drop_role",
715
+ sql: applyTemplate(loadSqlTemplate("uninit/03.role.sql"), vars),
716
+ });
717
+ }
718
+
719
+ return { monitoringUser, database, steps, dropRole };
720
+ }
721
+
722
+ export async function applyUninitPlan(params: {
723
+ client: PgClient;
724
+ plan: UninitPlan;
725
+ }): Promise<{ applied: string[]; errors: string[] }> {
726
+ const applied: string[] = [];
727
+ const errors: string[] = [];
728
+
729
+ // Helper to wrap a step execution in begin/commit
730
+ const executeStep = async (step: InitStep): Promise<void> => {
731
+ await params.client.query("begin;");
732
+ try {
733
+ await params.client.query(step.sql, step.params as any);
734
+ await params.client.query("commit;");
735
+ } catch (e) {
736
+ try {
737
+ await params.client.query("rollback;");
738
+ } catch {
739
+ // ignore
740
+ }
741
+ throw e;
742
+ }
743
+ };
744
+
745
+ // Apply steps in order - unlike init, uninit steps are not optional
746
+ // but we continue on errors to clean up as much as possible
747
+ for (const step of params.plan.steps) {
748
+ try {
749
+ await executeStep(step);
750
+ applied.push(step.name);
751
+ } catch (e) {
752
+ const msg = e instanceof Error ? e.message : String(e);
753
+ errors.push(`${step.name}: ${msg}`);
754
+ // Continue to try other steps
755
+ }
756
+ }
757
+
758
+ return { applied, errors };
759
+ }
760
+
761
+ export async function verifyInitSetup(params: {
762
+ client: PgClient;
763
+ database: string;
764
+ monitoringUser: string;
765
+ includeOptionalPermissions: boolean;
766
+ /** Provider type. Affects which checks are performed. */
767
+ provider?: DbProvider;
768
+ }): Promise<VerifyInitResult> {
769
+ // Use a repeatable-read snapshot so all checks see a consistent view.
770
+ await params.client.query("begin isolation level repeatable read;");
771
+ try {
772
+ const missingRequired: string[] = [];
773
+ const missingOptional: string[] = [];
774
+
775
+ const role = params.monitoringUser;
776
+ const db = params.database;
777
+ const provider = params.provider ?? "self-managed";
778
+
779
+ const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
780
+ const roleExists = (roleRes.rowCount ?? 0) > 0;
781
+ if (!roleExists) {
782
+ missingRequired.push(`role "${role}" does not exist`);
783
+ // If role is missing, other checks will error or be meaningless.
784
+ return { ok: false, missingRequired, missingOptional };
785
+ }
786
+
787
+ const connectRes = await params.client.query(
788
+ "select has_database_privilege($1, $2, 'CONNECT') as ok",
789
+ [role, db]
790
+ );
791
+ if (!connectRes.rows?.[0]?.ok) {
792
+ missingRequired.push(`CONNECT on database "${db}"`);
793
+ }
794
+
795
+ const pgMonitorRes = await params.client.query(
796
+ "select pg_has_role($1, 'pg_monitor', 'member') as ok",
797
+ [role]
798
+ );
799
+ if (!pgMonitorRes.rows?.[0]?.ok) {
800
+ missingRequired.push("membership in role pg_monitor");
801
+ }
802
+
803
+ const pgIndexRes = await params.client.query(
804
+ "select has_table_privilege($1, 'pg_catalog.pg_index', 'SELECT') as ok",
805
+ [role]
806
+ );
807
+ if (!pgIndexRes.rows?.[0]?.ok) {
808
+ missingRequired.push("SELECT on pg_catalog.pg_index");
809
+ }
810
+
811
+ // Check postgres_ai schema exists and is usable
812
+ const schemaExistsRes = await params.client.query(
813
+ "select has_schema_privilege($1, 'postgres_ai', 'USAGE') as ok",
814
+ [role]
815
+ );
816
+ if (!schemaExistsRes.rows?.[0]?.ok) {
817
+ missingRequired.push("USAGE on schema postgres_ai");
818
+ }
819
+
820
+ const viewExistsRes = await params.client.query("select to_regclass('postgres_ai.pg_statistic') is not null as ok");
821
+ if (!viewExistsRes.rows?.[0]?.ok) {
822
+ missingRequired.push("view postgres_ai.pg_statistic exists");
823
+ } else {
824
+ const viewPrivRes = await params.client.query(
825
+ "select has_table_privilege($1, 'postgres_ai.pg_statistic', 'SELECT') as ok",
826
+ [role]
827
+ );
828
+ if (!viewPrivRes.rows?.[0]?.ok) {
829
+ missingRequired.push("SELECT on view postgres_ai.pg_statistic");
830
+ }
831
+ }
832
+
833
+ const schemaUsageRes = await params.client.query(
834
+ "select has_schema_privilege($1, 'public', 'USAGE') as ok",
835
+ [role]
836
+ );
837
+ if (!schemaUsageRes.rows?.[0]?.ok) {
838
+ missingRequired.push("USAGE on schema public");
839
+ }
840
+
841
+ // Some providers don't allow setting search_path via ALTER USER - skip this check.
842
+ // TODO: Make this more flexible by allowing users to specify which checks to skip via config.
843
+ if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
844
+ const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
845
+ const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
846
+ const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
847
+ if (typeof spLine !== "string" || !spLine) {
848
+ missingRequired.push("role search_path is set");
849
+ } else {
850
+ // We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
851
+ const sp = spLine.toLowerCase();
852
+ if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
853
+ missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
854
+ }
855
+ }
856
+ }
857
+
858
+ // Check for helper functions
859
+ const explainFnRes = await params.client.query(
860
+ "select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok",
861
+ [role]
862
+ );
863
+ if (!explainFnRes.rows?.[0]?.ok) {
864
+ missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
865
+ }
866
+
867
+ const tableDescribeFnRes = await params.client.query(
868
+ "select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok",
869
+ [role]
870
+ );
871
+ if (!tableDescribeFnRes.rows?.[0]?.ok) {
872
+ missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
873
+ }
874
+
875
+ if (params.includeOptionalPermissions) {
876
+ // Optional RDS/Aurora extras
877
+ {
878
+ const extRes = await params.client.query("select 1 from pg_extension where extname = 'rds_tools'");
879
+ if ((extRes.rowCount ?? 0) === 0) {
880
+ missingOptional.push("extension rds_tools");
881
+ } else {
882
+ const fnRes = await params.client.query(
883
+ "select has_function_privilege($1, 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok",
884
+ [role]
885
+ );
886
+ if (!fnRes.rows?.[0]?.ok) {
887
+ missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
888
+ }
889
+ }
890
+ }
891
+
892
+ // Optional self-managed extras
893
+ const optionalFns = [
894
+ "pg_catalog.pg_stat_file(text)",
895
+ "pg_catalog.pg_stat_file(text, boolean)",
896
+ "pg_catalog.pg_ls_dir(text)",
897
+ "pg_catalog.pg_ls_dir(text, boolean, boolean)",
898
+ ];
899
+ for (const fn of optionalFns) {
900
+ const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
901
+ if (!fnRes.rows?.[0]?.ok) {
902
+ missingOptional.push(`EXECUTE on ${fn}`);
903
+ }
904
+ }
905
+ }
906
+
907
+ return { ok: missingRequired.length === 0, missingRequired, missingOptional };
908
+ } finally {
909
+ // Read-only: rollback to release snapshot; do not mask original errors.
910
+ try {
911
+ await params.client.query("rollback;");
912
+ } catch {
913
+ // ignore
914
+ }
915
+ }
916
+ }
917
+
452
918