postgresai 0.16.0-rc.4 → 0.16.0

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 (85) hide show
  1. package/README.md +154 -0
  2. package/dist/bin/postgres-ai.js +2911 -255
  3. package/package.json +12 -3
  4. package/schemas/A002.schema.json +63 -0
  5. package/schemas/A003.schema.json +73 -0
  6. package/schemas/A004.schema.json +81 -0
  7. package/schemas/A007.schema.json +71 -0
  8. package/schemas/A013.schema.json +61 -0
  9. package/schemas/D001.schema.json +71 -0
  10. package/schemas/D004.schema.json +136 -0
  11. package/schemas/F001.schema.json +73 -0
  12. package/schemas/F002.schema.json +108 -0
  13. package/schemas/F003.schema.json +138 -0
  14. package/schemas/F004.schema.json +125 -0
  15. package/schemas/F005.schema.json +131 -0
  16. package/schemas/F009.schema.json +155 -0
  17. package/schemas/G001.schema.json +135 -0
  18. package/schemas/G003.schema.json +90 -0
  19. package/schemas/H001.schema.json +141 -0
  20. package/schemas/H002.schema.json +129 -0
  21. package/schemas/H004.schema.json +128 -0
  22. package/schemas/I001.schema.json +149 -0
  23. package/schemas/K001.schema.json +161 -0
  24. package/schemas/K003.schema.json +163 -0
  25. package/schemas/K004.schema.json +110 -0
  26. package/schemas/K005.schema.json +110 -0
  27. package/schemas/K006.schema.json +110 -0
  28. package/schemas/K007.schema.json +110 -0
  29. package/schemas/K008.schema.json +110 -0
  30. package/schemas/M001.schema.json +119 -0
  31. package/schemas/M002.schema.json +110 -0
  32. package/schemas/M003.schema.json +128 -0
  33. package/schemas/N001.schema.json +161 -0
  34. package/schemas/query.schema.json +62 -0
  35. package/CHANGELOG.md +0 -11
  36. package/bin/postgres-ai.ts +0 -5578
  37. package/bun.lock +0 -258
  38. package/bunfig.toml +0 -20
  39. package/lib/aas-onboard.ts +0 -251
  40. package/lib/auth-server.ts +0 -285
  41. package/lib/checkup-api.ts +0 -526
  42. package/lib/checkup-dictionary.ts +0 -103
  43. package/lib/checkup-summary.ts +0 -338
  44. package/lib/checkup.ts +0 -2261
  45. package/lib/config.ts +0 -171
  46. package/lib/init.ts +0 -1152
  47. package/lib/instances.ts +0 -245
  48. package/lib/issues.ts +0 -1060
  49. package/lib/mcp-server.ts +0 -667
  50. package/lib/metrics-loader.ts +0 -134
  51. package/lib/pkce.ts +0 -79
  52. package/lib/reports.ts +0 -373
  53. package/lib/storage.ts +0 -367
  54. package/lib/supabase.ts +0 -826
  55. package/lib/util.ts +0 -134
  56. package/packages/postgres-ai/README.md +0 -26
  57. package/packages/postgres-ai/bin/postgres-ai.js +0 -27
  58. package/packages/postgres-ai/package.json +0 -27
  59. package/scripts/embed-checkup-dictionary.ts +0 -115
  60. package/scripts/embed-metrics.ts +0 -160
  61. package/scripts/generate-release-notes.ts +0 -668
  62. package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
  63. package/test/aas-onboard.test.ts +0 -301
  64. package/test/auth.test.ts +0 -287
  65. package/test/checkup.integration.test.ts +0 -413
  66. package/test/checkup.test.ts +0 -3626
  67. package/test/compose-cmd.test.ts +0 -120
  68. package/test/config-consistency.test.ts +0 -352
  69. package/test/init.integration.test.ts +0 -438
  70. package/test/init.test.ts +0 -1816
  71. package/test/issues.cli.test.ts +0 -1162
  72. package/test/issues.test.ts +0 -456
  73. package/test/mcp-server.test.ts +0 -2530
  74. package/test/monitoring.test.ts +0 -746
  75. package/test/permission-check-sql.test.ts +0 -116
  76. package/test/reports.cli.test.ts +0 -793
  77. package/test/reports.test.ts +0 -977
  78. package/test/schema-validation.test.ts +0 -231
  79. package/test/storage.test.ts +0 -935
  80. package/test/supabase.test.ts +0 -709
  81. package/test/targets-add-config.test.ts +0 -28
  82. package/test/test-utils.ts +0 -190
  83. package/test/upgrade.test.ts +0 -1056
  84. package/test/util.test.ts +0 -44
  85. package/tsconfig.json +0 -20
package/lib/init.ts DELETED
@@ -1,1152 +0,0 @@
1
- import { randomBytes } from "crypto";
2
- import { URL, fileURLToPath } from "url";
3
- import type { ConnectionOptions as TlsConnectionOptions } from "tls";
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
- }
35
-
36
- export type PgClientConfig = {
37
- connectionString?: string;
38
- host?: string;
39
- port?: number;
40
- user?: string;
41
- password?: string;
42
- database?: string;
43
- ssl?: boolean | TlsConnectionOptions;
44
- };
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
-
80
- export type AdminConnection = {
81
- clientConfig: PgClientConfig;
82
- display: string;
83
- /** True if SSL fallback is enabled (try SSL first, fall back to non-SSL on failure). */
84
- sslFallbackEnabled?: boolean;
85
- };
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, connectionTimeoutMillis: 10_000 } as any);
131
- await client.connect();
132
- // Set a default statement timeout to prevent runaway queries
133
- await client.query("SET statement_timeout = '30s'");
134
- return client;
135
- };
136
-
137
- // If SSL was explicitly set or no SSL configured, just try once
138
- if (!adminConn.sslFallbackEnabled) {
139
- const client = await tryConnect(adminConn.clientConfig);
140
- return { client, usedSsl: !!adminConn.clientConfig.ssl };
141
- }
142
-
143
- // sslmode=prefer behavior: try SSL first, fallback to non-SSL
144
- try {
145
- const client = await tryConnect(adminConn.clientConfig);
146
- return { client, usedSsl: true };
147
- } catch (sslErr) {
148
- if (!isSslNegotiationError(sslErr)) {
149
- // Not an SSL error, don't retry
150
- throw sslErr;
151
- }
152
-
153
- if (verbose) {
154
- console.error("SSL connection failed, retrying without SSL...");
155
- }
156
-
157
- // Retry without SSL
158
- const noSslConfig: PgClientConfig = { ...adminConn.clientConfig, ssl: false };
159
- try {
160
- const client = await tryConnect(noSslConfig);
161
- return { client, usedSsl: false };
162
- } catch (noSslErr) {
163
- // If non-SSL also fails, check if it's "SSL required" - throw that instead
164
- if (isSslNegotiationError(noSslErr)) {
165
- const msg = (noSslErr as any)?.message || "";
166
- if (msg.toLowerCase().includes("ssl") && msg.toLowerCase().includes("required")) {
167
- // Server requires SSL but SSL attempt failed - throw original SSL error
168
- throw sslErr;
169
- }
170
- }
171
- // Throw the non-SSL error (it's more relevant since SSL attempt also failed)
172
- throw noSslErr;
173
- }
174
- }
175
- }
176
-
177
- export type InitStep = {
178
- name: string;
179
- sql: string;
180
- params?: unknown[];
181
- optional?: boolean;
182
- };
183
-
184
- export type InitPlan = {
185
- monitoringUser: string;
186
- database: string;
187
- steps: InitStep[];
188
- };
189
-
190
- function sqlDir(): string {
191
- // Handle both development and production paths
192
- // Development: lib/init.ts -> ../sql
193
- // Production (bundled): dist/bin/postgres-ai.js -> ../sql (copied during build)
194
- //
195
- // IMPORTANT: Use import.meta.url instead of __dirname because bundlers (bun/esbuild)
196
- // bake in __dirname at build time, while import.meta.url resolves at runtime.
197
- const currentFile = fileURLToPath(import.meta.url);
198
- const currentDir = path.dirname(currentFile);
199
-
200
- const candidates = [
201
- path.resolve(currentDir, "..", "sql"), // bundled: dist/bin -> dist/sql
202
- path.resolve(currentDir, "..", "..", "sql"), // dev from lib: lib -> ../sql
203
- ];
204
-
205
- for (const candidate of candidates) {
206
- if (fs.existsSync(candidate)) {
207
- return candidate;
208
- }
209
- }
210
- throw new Error(`SQL directory not found. Searched: ${candidates.join(", ")}`);
211
- }
212
-
213
- function loadSqlTemplate(filename: string): string {
214
- const p = path.join(sqlDir(), filename);
215
- return fs.readFileSync(p, "utf8");
216
- }
217
-
218
- function applyTemplate(sql: string, vars: Record<string, string>): string {
219
- return sql.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => {
220
- const v = vars[key];
221
- if (v === undefined) throw new Error(`Missing SQL template var: ${key}`);
222
- return v;
223
- });
224
- }
225
-
226
- function quoteIdent(ident: string): string {
227
- // Always quote. Escape embedded quotes by doubling.
228
- if (ident.includes("\0")) {
229
- throw new Error("Identifier cannot contain null bytes");
230
- }
231
- return `"${ident.replace(/"/g, "\"\"")}"`;
232
- }
233
-
234
- function quoteLiteral(value: string): string {
235
- // Single-quote and escape embedded quotes by doubling.
236
- // This is used where Postgres grammar requires a literal (e.g., CREATE/ALTER ROLE PASSWORD).
237
- if (value.includes("\0")) {
238
- throw new Error("Literal cannot contain null bytes");
239
- }
240
- return `'${value.replace(/'/g, "''")}'`;
241
- }
242
-
243
- export function redactPasswordsInSql(sql: string): string {
244
- // Replace PASSWORD '<literal>' (handles doubled quotes inside).
245
- return sql.replace(/password\s+'(?:''|[^'])*'/gi, "password '<redacted>'");
246
- }
247
-
248
- export function maskConnectionString(dbUrl: string): string {
249
- // Hide password if present (postgresql://user:pass@host/db).
250
- try {
251
- const u = new URL(dbUrl);
252
- if (u.password) u.password = "*****";
253
- return u.toString();
254
- } catch {
255
- return dbUrl.replace(/\/\/([^:/?#]+):([^@/?#]+)@/g, "//$1:*****@");
256
- }
257
- }
258
-
259
- function isLikelyUri(value: string): boolean {
260
- return /^postgres(ql)?:\/\//i.test(value.trim());
261
- }
262
-
263
- function tokenizeConninfo(input: string): string[] {
264
- const s = input.trim();
265
- const tokens: string[] = [];
266
- let i = 0;
267
-
268
- const isSpace = (ch: string) => ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
269
-
270
- while (i < s.length) {
271
- while (i < s.length && isSpace(s[i]!)) i++;
272
- if (i >= s.length) break;
273
-
274
- let tok = "";
275
- let inSingle = false;
276
- while (i < s.length) {
277
- const ch = s[i]!;
278
- if (!inSingle && isSpace(ch)) break;
279
-
280
- if (ch === "'" && !inSingle) {
281
- inSingle = true;
282
- i++;
283
- continue;
284
- }
285
- if (ch === "'" && inSingle) {
286
- inSingle = false;
287
- i++;
288
- continue;
289
- }
290
-
291
- if (ch === "\\" && i + 1 < s.length) {
292
- tok += s[i + 1]!;
293
- i += 2;
294
- continue;
295
- }
296
-
297
- tok += ch;
298
- i++;
299
- }
300
-
301
- tokens.push(tok);
302
- while (i < s.length && isSpace(s[i]!)) i++;
303
- }
304
-
305
- return tokens;
306
- }
307
-
308
- export function parseLibpqConninfo(input: string): PgClientConfig {
309
- const tokens = tokenizeConninfo(input);
310
- const cfg: PgClientConfig = {};
311
- let sslmode: string | undefined;
312
-
313
- for (const t of tokens) {
314
- const eq = t.indexOf("=");
315
- if (eq <= 0) continue;
316
- const key = t.slice(0, eq).trim();
317
- const rawVal = t.slice(eq + 1);
318
- const val = rawVal.trim();
319
- if (!key) continue;
320
-
321
- switch (key) {
322
- case "host":
323
- cfg.host = val;
324
- break;
325
- case "port": {
326
- const p = Number(val);
327
- if (Number.isFinite(p)) cfg.port = p;
328
- break;
329
- }
330
- case "user":
331
- cfg.user = val;
332
- break;
333
- case "password":
334
- cfg.password = val;
335
- break;
336
- case "dbname":
337
- case "database":
338
- cfg.database = val;
339
- break;
340
- case "sslmode":
341
- sslmode = val;
342
- break;
343
- // ignore everything else (options, application_name, etc.)
344
- default:
345
- break;
346
- }
347
- }
348
-
349
- // Apply SSL configuration based on sslmode
350
- if (sslmode) {
351
- cfg.ssl = sslModeToConfig(sslmode);
352
- }
353
-
354
- return cfg;
355
- }
356
-
357
- export function describePgConfig(cfg: PgClientConfig): string {
358
- if (cfg.connectionString) return maskConnectionString(cfg.connectionString);
359
- const user = cfg.user ? cfg.user : "<user>";
360
- const host = cfg.host ? cfg.host : "<host>";
361
- const port = cfg.port ? String(cfg.port) : "<port>";
362
- const db = cfg.database ? cfg.database : "<db>";
363
- // Don't include password
364
- return `postgresql://${user}:*****@${host}:${port}/${db}`;
365
- }
366
-
367
- export function resolveAdminConnection(opts: {
368
- conn?: string;
369
- dbUrlFlag?: string;
370
- host?: string;
371
- port?: string | number;
372
- username?: string;
373
- dbname?: string;
374
- adminPassword?: string;
375
- envPassword?: string;
376
- }): AdminConnection {
377
- const conn = (opts.conn || "").trim();
378
- const dbUrlFlag = (opts.dbUrlFlag || "").trim();
379
-
380
- // Resolve explicit SSL setting from environment (undefined = auto-detect)
381
- const explicitSsl = process.env.PGSSLMODE;
382
-
383
- // NOTE: passwords alone (PGPASSWORD / --admin-password) do NOT constitute a connection.
384
- // We require at least some connection addressing (host/port/user/db) if no positional arg / --db-url is provided.
385
- const hasConnDetails = !!(opts.host || opts.port || opts.username || opts.dbname);
386
-
387
- if (conn && dbUrlFlag) {
388
- throw new Error("Provide either positional connection string or --db-url, not both");
389
- }
390
-
391
- if (conn || dbUrlFlag) {
392
- const v = conn || dbUrlFlag;
393
- if (isLikelyUri(v)) {
394
- const urlSslMode = extractSslModeFromUri(v);
395
- const effectiveSslMode = explicitSsl || urlSslMode;
396
- // SSL priority: PGSSLMODE env > URL param > auto (sslmode=prefer behavior)
397
- const sslConfig = effectiveSslMode
398
- ? sslModeToConfig(effectiveSslMode)
399
- : { rejectUnauthorized: false }; // Default: try SSL (with fallback)
400
- // Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
401
- const shouldFallback = !effectiveSslMode ||
402
- effectiveSslMode.toLowerCase() === "prefer" ||
403
- effectiveSslMode.toLowerCase() === "allow";
404
- // Strip sslmode from URI so pg uses our ssl config object instead
405
- const cleanUri = stripSslModeFromUri(v);
406
- return {
407
- clientConfig: { connectionString: cleanUri, ssl: sslConfig },
408
- display: maskConnectionString(v),
409
- sslFallbackEnabled: shouldFallback,
410
- };
411
- }
412
- // libpq conninfo (dbname=... host=...)
413
- const cfg = parseLibpqConninfo(v);
414
- if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
415
- const cfgHadSsl = cfg.ssl !== undefined;
416
- if (cfg.ssl === undefined) {
417
- if (explicitSsl) cfg.ssl = sslModeToConfig(explicitSsl);
418
- else cfg.ssl = { rejectUnauthorized: false }; // Default: try SSL (with fallback)
419
- }
420
- // Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
421
- const shouldFallback = (!explicitSsl && !cfgHadSsl) ||
422
- (!!explicitSsl && (explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow"));
423
- return {
424
- clientConfig: cfg,
425
- display: describePgConfig(cfg),
426
- sslFallbackEnabled: shouldFallback,
427
- };
428
- }
429
-
430
- if (!hasConnDetails) {
431
- // Keep this message short: the CLI prints full help (including examples) on this error.
432
- throw new Error("Connection is required.");
433
- }
434
-
435
- const cfg: PgClientConfig = {};
436
- if (opts.host) cfg.host = opts.host;
437
- if (opts.port !== undefined && opts.port !== "") {
438
- const p = Number(opts.port);
439
- if (!Number.isFinite(p) || !Number.isInteger(p) || p <= 0 || p > 65535) {
440
- throw new Error(`Invalid port value: ${String(opts.port)}`);
441
- }
442
- cfg.port = p;
443
- }
444
- if (opts.username) cfg.user = opts.username;
445
- if (opts.dbname) cfg.database = opts.dbname;
446
- if (opts.adminPassword) cfg.password = opts.adminPassword;
447
- if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
448
- if (explicitSsl) {
449
- cfg.ssl = sslModeToConfig(explicitSsl);
450
- // Enable fallback for explicit "prefer"/"allow"
451
- const shouldFallback = explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow";
452
- return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: shouldFallback };
453
- }
454
- // Default: try SSL with fallback (sslmode=prefer behavior)
455
- cfg.ssl = { rejectUnauthorized: false };
456
- return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: true };
457
- }
458
-
459
- /**
460
- * Generate a cryptographically secure random password for the monitoring role.
461
- *
462
- * Encoding note — bytes vs output length:
463
- * - hex: N bytes → 2N characters (24 bytes → 48 hex chars)
464
- * - base64: N bytes → ⌈4N/3⌉ chars (24 bytes → 32 base64url chars, no padding)
465
- *
466
- * We use base64url (RFC 4648 §5) because it is shorter than hex and safe in URLs,
467
- * connection strings, and shell variables without quoting.
468
- */
469
- function generateMonitoringPassword(): string {
470
- // 24 random bytes → 32 base64url characters (no padding).
471
- // Note: randomBytes() throws on failure; we add a tiny sanity check for unexpected output.
472
- const password = randomBytes(24).toString("base64url");
473
- if (password.length < 30) {
474
- throw new Error("Password generation failed: unexpected output length");
475
- }
476
- return password;
477
- }
478
-
479
- export async function resolveMonitoringPassword(opts: {
480
- passwordFlag?: string;
481
- passwordEnv?: string;
482
- monitoringUser: string;
483
- }): Promise<{ password: string; generated: boolean }> {
484
- const fromFlag = (opts.passwordFlag || "").trim();
485
- if (fromFlag) return { password: fromFlag, generated: false };
486
-
487
- const fromEnv = (opts.passwordEnv || "").trim();
488
- if (fromEnv) return { password: fromEnv, generated: false };
489
-
490
- // Default: auto-generate (safer than prompting; works in non-interactive mode).
491
- return { password: generateMonitoringPassword(), generated: true };
492
- }
493
-
494
- export async function buildInitPlan(params: {
495
- database: string;
496
- monitoringUser?: string;
497
- monitoringPassword: string;
498
- includeOptionalPermissions: boolean;
499
- /** Provider type. Affects which steps are included. Defaults to "self-managed". */
500
- provider?: DbProvider;
501
- }): Promise<InitPlan> {
502
- // NOTE: kept async for API stability / potential future async template loading.
503
- const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
504
- const database = params.database;
505
- const provider = params.provider ?? "self-managed";
506
-
507
- const qRole = quoteIdent(monitoringUser);
508
- const qDb = quoteIdent(database);
509
- const qPw = quoteLiteral(params.monitoringPassword);
510
- const qRoleNameLit = quoteLiteral(monitoringUser);
511
-
512
- const steps: InitStep[] = [];
513
-
514
- const vars: Record<string, string> = {
515
- ROLE_IDENT: qRole,
516
- DB_IDENT: qDb,
517
- };
518
-
519
- // Some providers (e.g., Supabase) manage users externally - skip role creation.
520
- // TODO: Make this more flexible by allowing users to specify which steps to skip via config.
521
- if (!SKIP_ROLE_CREATION_PROVIDERS.includes(provider)) {
522
- // Role creation/update is done in one template file.
523
- // Always use a single DO block to avoid race conditions between "role exists?" checks and CREATE USER.
524
- // We:
525
- // - create role if missing (and handle duplicate_object in case another session created it concurrently),
526
- // - then ALTER ROLE to ensure the password is set to the desired value.
527
- const roleStmt = `do $$ begin
528
- if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
529
- begin
530
- create user ${qRole} with password ${qPw};
531
- exception when duplicate_object then
532
- null;
533
- end;
534
- end if;
535
- alter user ${qRole} with password ${qPw};
536
- end $$;`;
537
-
538
- const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
539
- steps.push({ name: "01.role", sql: roleSql });
540
- }
541
-
542
- // Extensions should be created before permissions (so we can grant permissions on them)
543
- steps.push({
544
- name: "02.extensions",
545
- sql: loadSqlTemplate("02.extensions.sql"),
546
- });
547
-
548
- let permissionsSql = applyTemplate(loadSqlTemplate("03.permissions.sql"), vars);
549
-
550
- // Some providers restrict ALTER USER - remove those statements.
551
- // TODO: Make this more flexible by allowing users to specify which statements to skip via config.
552
- if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
553
- // Remove the entire search_path DO block (marked with SEARCH_PATH_BLOCK_START/END)
554
- // since it contains ALTER USER and can't be line-filtered without breaking the DO block.
555
- permissionsSql = permissionsSql.replace(
556
- /-- \[SEARCH_PATH_BLOCK_START\][\s\S]*?-- \[SEARCH_PATH_BLOCK_END\]\n?/,
557
- ""
558
- );
559
- }
560
-
561
- steps.push({
562
- name: "03.permissions",
563
- sql: permissionsSql,
564
- });
565
-
566
- // Helper functions (SECURITY DEFINER) for plan analysis and table info
567
- steps.push({
568
- name: "06.helpers",
569
- sql: applyTemplate(loadSqlTemplate("06.helpers.sql"), vars),
570
- });
571
-
572
- if (params.includeOptionalPermissions) {
573
- steps.push(
574
- {
575
- name: "04.optional_rds",
576
- sql: applyTemplate(loadSqlTemplate("04.optional_rds.sql"), vars),
577
- optional: true,
578
- },
579
- {
580
- name: "05.optional_self_managed",
581
- sql: applyTemplate(loadSqlTemplate("05.optional_self_managed.sql"), vars),
582
- optional: true,
583
- }
584
- );
585
- }
586
-
587
- return { monitoringUser, database, steps };
588
- }
589
-
590
- export async function applyInitPlan(params: {
591
- client: PgClient;
592
- plan: InitPlan;
593
- verbose?: boolean;
594
- }): Promise<{ applied: string[]; skippedOptional: string[] }> {
595
- const applied: string[] = [];
596
- const skippedOptional: string[] = [];
597
-
598
- // Helper to wrap a step execution in begin/commit
599
- const executeStep = async (step: InitStep): Promise<void> => {
600
- await params.client.query("begin;");
601
- try {
602
- await params.client.query(step.sql, step.params as any);
603
- await params.client.query("commit;");
604
- } catch (e) {
605
- // Rollback errors should never mask the original failure.
606
- try {
607
- await params.client.query("rollback;");
608
- } catch {
609
- // ignore
610
- }
611
- throw e;
612
- }
613
- };
614
-
615
- // Apply non-optional steps, each in its own transaction
616
- for (const step of params.plan.steps.filter((s) => !s.optional)) {
617
- try {
618
- await executeStep(step);
619
- applied.push(step.name);
620
- } catch (e) {
621
- const msg = e instanceof Error ? e.message : String(e);
622
- const errAny = e as any;
623
- const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
624
- // Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
625
- const pgErrorFields = [
626
- "code",
627
- "detail",
628
- "hint",
629
- "position",
630
- "internalPosition",
631
- "internalQuery",
632
- "where",
633
- "schema",
634
- "table",
635
- "column",
636
- "dataType",
637
- "constraint",
638
- "file",
639
- "line",
640
- "routine",
641
- ] as const;
642
- if (errAny && typeof errAny === "object") {
643
- for (const field of pgErrorFields) {
644
- if (errAny[field] !== undefined) wrapped[field] = errAny[field];
645
- }
646
- }
647
- if (e instanceof Error && e.stack) {
648
- wrapped.stack = e.stack;
649
- }
650
- throw wrapped;
651
- }
652
- }
653
-
654
- // Apply optional steps, each in its own transaction (failure doesn't abort)
655
- for (const step of params.plan.steps.filter((s) => s.optional)) {
656
- try {
657
- await executeStep(step);
658
- applied.push(step.name);
659
- } catch {
660
- skippedOptional.push(step.name);
661
- // best-effort: ignore
662
- }
663
- }
664
-
665
- return { applied, skippedOptional };
666
- }
667
-
668
- export type VerifyInitResult = {
669
- ok: boolean;
670
- missingRequired: string[];
671
- missingOptional: string[];
672
- };
673
-
674
- /** A single permission check result from the preflight query. */
675
- export type PermissionCheckRow = {
676
- permission_name: string;
677
- status: "required" | "optional";
678
- /**
679
- * Whether the permission is granted.
680
- * - `true` — permission is granted
681
- * - `false` — permission is explicitly denied
682
- * - `null` — check was skipped (e.g., object does not exist, so the privilege
683
- * check is inapplicable — such as SELECT on a view that hasn't been created)
684
- */
685
- granted: boolean | null;
686
- fix_command: string | null;
687
- };
688
-
689
- /**
690
- * Result of the preflight permission check for the current DB user.
691
- *
692
- * - `ok` is `true` when `missingRequired` is empty.
693
- * - `rows` contains every check (for inspection / logging).
694
- * - `missingRequired` / `missingOptional` are filtered subsets of `rows`
695
- * where the permission is not granted (`granted !== true`).
696
- */
697
- export type PreflightPermissionResult = {
698
- ok: boolean;
699
- rows: PermissionCheckRow[];
700
- missingRequired: PermissionCheckRow[];
701
- missingOptional: PermissionCheckRow[];
702
- };
703
-
704
- export type UninitPlan = {
705
- monitoringUser: string;
706
- database: string;
707
- steps: InitStep[];
708
- /** If true, also drop the monitoring role. If false, only revoke permissions. */
709
- dropRole: boolean;
710
- };
711
-
712
- export async function buildUninitPlan(params: {
713
- database: string;
714
- monitoringUser?: string;
715
- /** If true, drop the role entirely. If false, only revoke permissions/drop objects. */
716
- dropRole?: boolean;
717
- /** Provider type. Affects which steps are included. Defaults to "self-managed". */
718
- provider?: DbProvider;
719
- }): Promise<UninitPlan> {
720
- const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
721
- const database = params.database;
722
- const provider = params.provider ?? "self-managed";
723
- const dropRole = params.dropRole ?? true;
724
-
725
- const qRole = quoteIdent(monitoringUser);
726
- const qDb = quoteIdent(database);
727
- const qRoleLiteral = quoteLiteral(monitoringUser);
728
-
729
- const steps: InitStep[] = [];
730
-
731
- const vars: Record<string, string> = {
732
- ROLE_IDENT: qRole,
733
- DB_IDENT: qDb,
734
- ROLE_LITERAL: qRoleLiteral,
735
- };
736
-
737
- // Step 1: Drop helper functions
738
- steps.push({
739
- name: "01.drop_helpers",
740
- sql: applyTemplate(loadSqlTemplate("uninit/01.helpers.sql"), vars),
741
- });
742
-
743
- // Step 2: Drop view, revoke permissions, drop schema
744
- steps.push({
745
- name: "02.revoke_permissions",
746
- sql: applyTemplate(loadSqlTemplate("uninit/02.permissions.sql"), vars),
747
- });
748
-
749
- // Step 3: Drop the role (only if requested and provider allows it)
750
- if (dropRole && !SKIP_ROLE_CREATION_PROVIDERS.includes(provider)) {
751
- steps.push({
752
- name: "03.drop_role",
753
- sql: applyTemplate(loadSqlTemplate("uninit/03.role.sql"), vars),
754
- });
755
- }
756
-
757
- return { monitoringUser, database, steps, dropRole };
758
- }
759
-
760
- export async function applyUninitPlan(params: {
761
- client: PgClient;
762
- plan: UninitPlan;
763
- }): Promise<{ applied: string[]; errors: string[] }> {
764
- const applied: string[] = [];
765
- const errors: string[] = [];
766
-
767
- // Helper to wrap a step execution in begin/commit
768
- const executeStep = async (step: InitStep): Promise<void> => {
769
- await params.client.query("begin;");
770
- try {
771
- await params.client.query(step.sql, step.params as any);
772
- await params.client.query("commit;");
773
- } catch (e) {
774
- try {
775
- await params.client.query("rollback;");
776
- } catch {
777
- // ignore
778
- }
779
- throw e;
780
- }
781
- };
782
-
783
- // Apply steps in order - unlike init, uninit steps are not optional
784
- // but we continue on errors to clean up as much as possible
785
- for (const step of params.plan.steps) {
786
- try {
787
- await executeStep(step);
788
- applied.push(step.name);
789
- } catch (e) {
790
- const msg = e instanceof Error ? e.message : String(e);
791
- errors.push(`${step.name}: ${msg}`);
792
- // Continue to try other steps
793
- }
794
- }
795
-
796
- return { applied, errors };
797
- }
798
-
799
- export async function verifyInitSetup(params: {
800
- client: PgClient;
801
- database: string;
802
- monitoringUser: string;
803
- includeOptionalPermissions: boolean;
804
- /** Provider type. Affects which checks are performed. */
805
- provider?: DbProvider;
806
- }): Promise<VerifyInitResult> {
807
- // Use a repeatable-read snapshot so all checks see a consistent view.
808
- await params.client.query("begin isolation level repeatable read;");
809
- try {
810
- const missingRequired: string[] = [];
811
- const missingOptional: string[] = [];
812
-
813
- const role = params.monitoringUser;
814
- const db = params.database;
815
- const provider = params.provider ?? "self-managed";
816
-
817
- const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
818
- const roleExists = (roleRes.rowCount ?? 0) > 0;
819
- if (!roleExists) {
820
- missingRequired.push(`role "${role}" does not exist`);
821
- // If role is missing, other checks will error or be meaningless.
822
- return { ok: false, missingRequired, missingOptional };
823
- }
824
-
825
- const connectRes = await params.client.query(
826
- "select has_database_privilege($1, $2, 'CONNECT') as ok",
827
- [role, db]
828
- );
829
- if (!connectRes.rows?.[0]?.ok) {
830
- missingRequired.push(`CONNECT on database "${db}"`);
831
- }
832
-
833
- const pgMonitorRes = await params.client.query(
834
- "select pg_has_role($1, 'pg_monitor', 'member') as ok",
835
- [role]
836
- );
837
- if (!pgMonitorRes.rows?.[0]?.ok) {
838
- missingRequired.push("membership in role pg_monitor");
839
- }
840
-
841
- const pgIndexRes = await params.client.query(
842
- "select has_table_privilege($1, 'pg_catalog.pg_index', 'SELECT') as ok",
843
- [role]
844
- );
845
- if (!pgIndexRes.rows?.[0]?.ok) {
846
- missingRequired.push("SELECT on pg_catalog.pg_index");
847
- }
848
-
849
- // Check postgres_ai schema exists and is usable
850
- const schemaExistsRes = await params.client.query(
851
- "select has_schema_privilege($1, 'postgres_ai', 'USAGE') as ok",
852
- [role]
853
- );
854
- if (!schemaExistsRes.rows?.[0]?.ok) {
855
- missingRequired.push("USAGE on schema postgres_ai");
856
- }
857
-
858
- const viewExistsRes = await params.client.query(`
859
- select case
860
- when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
861
- else to_regclass('postgres_ai.pg_statistic') is not null
862
- end as ok
863
- `);
864
- if (!viewExistsRes.rows?.[0]?.ok) {
865
- missingRequired.push("view postgres_ai.pg_statistic exists");
866
- } else {
867
- const viewPrivRes = await params.client.query(
868
- "select has_table_privilege($1, 'postgres_ai.pg_statistic', 'SELECT') as ok",
869
- [role]
870
- );
871
- if (!viewPrivRes.rows?.[0]?.ok) {
872
- missingRequired.push("SELECT on view postgres_ai.pg_statistic");
873
- }
874
- }
875
-
876
- const schemaUsageRes = await params.client.query(
877
- "select has_schema_privilege($1, 'public', 'USAGE') as ok",
878
- [role]
879
- );
880
- if (!schemaUsageRes.rows?.[0]?.ok) {
881
- missingRequired.push("USAGE on schema public");
882
- }
883
-
884
- // Check access to pg_stat_statements extension schema (may be 'extensions' on Supabase)
885
- const extSchemaRes = await params.client.query(`
886
- select n.nspname as schema
887
- from pg_extension e
888
- join pg_namespace n on e.extnamespace = n.oid
889
- where e.extname = 'pg_stat_statements'
890
- `);
891
- const extSchema = extSchemaRes.rows?.[0]?.schema;
892
- if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
893
- const extSchemaUsageRes = await params.client.query(
894
- "select has_schema_privilege($1, $2, 'USAGE') as ok",
895
- [role, extSchema]
896
- );
897
- if (!extSchemaUsageRes.rows?.[0]?.ok) {
898
- missingRequired.push(`USAGE on schema ${extSchema} (pg_stat_statements location)`);
899
- }
900
- }
901
-
902
- // Some providers don't allow setting search_path via ALTER USER - skip this check.
903
- // TODO: Make this more flexible by allowing users to specify which checks to skip via config.
904
- if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
905
- const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
906
- const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
907
- const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
908
- if (typeof spLine !== "string" || !spLine) {
909
- missingRequired.push("role search_path is set");
910
- } else {
911
- // We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
912
- // Also verify search_path includes the pg_stat_statements schema if in a non-standard location.
913
- const sp = spLine.toLowerCase();
914
- if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
915
- missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
916
- }
917
- // If pg_stat_statements is in a non-standard schema (e.g., 'extensions' on Supabase), verify it's in search_path
918
- if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
919
- if (!sp.includes(extSchema.toLowerCase())) {
920
- missingRequired.push(`role search_path includes ${extSchema} (pg_stat_statements location)`);
921
- }
922
- }
923
- }
924
- }
925
-
926
- // Check for helper functions
927
- const tableDescribeFnRes = await params.client.query(
928
- "select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok",
929
- [role]
930
- );
931
- if (!tableDescribeFnRes.rows?.[0]?.ok) {
932
- missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
933
- }
934
-
935
- if (params.includeOptionalPermissions) {
936
- // Optional RDS/Aurora extras
937
- {
938
- const extRes = await params.client.query("select 1 from pg_extension where extname = 'rds_tools'");
939
- if ((extRes.rowCount ?? 0) === 0) {
940
- missingOptional.push("extension rds_tools");
941
- } else {
942
- const fnRes = await params.client.query(
943
- "select has_function_privilege($1, 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok",
944
- [role]
945
- );
946
- if (!fnRes.rows?.[0]?.ok) {
947
- missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
948
- }
949
- }
950
- }
951
-
952
- // Optional self-managed extras
953
- const optionalFns = [
954
- "pg_catalog.pg_stat_file(text)",
955
- "pg_catalog.pg_stat_file(text, boolean)",
956
- "pg_catalog.pg_ls_dir(text)",
957
- "pg_catalog.pg_ls_dir(text, boolean, boolean)",
958
- ];
959
- for (const fn of optionalFns) {
960
- const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
961
- if (!fnRes.rows?.[0]?.ok) {
962
- missingOptional.push(`EXECUTE on ${fn}`);
963
- }
964
- }
965
- }
966
-
967
- return { ok: missingRequired.length === 0, missingRequired, missingOptional };
968
- } finally {
969
- // Read-only: rollback to release snapshot; do not mask original errors.
970
- try {
971
- await params.client.query("rollback;");
972
- } catch {
973
- // ignore
974
- }
975
- }
976
- }
977
-
978
- /**
979
- * Check that the currently connected DB user has sufficient permissions for
980
- * monitoring operations. Returns structured results with fix commands.
981
- *
982
- * Required permissions cause startup to fail; optional ones produce warnings.
983
- *
984
- * @param client An already-connected PostgreSQL client.
985
- * @returns A {@link PreflightPermissionResult} with per-check rows and
986
- * filtered `missingRequired` / `missingOptional` arrays.
987
- * @throws Propagates database errors (network, permission denied on catalog
988
- * tables, timeout) to the caller.
989
- */
990
- export async function checkCurrentUserPermissions(
991
- client: PgClient
992
- ): Promise<PreflightPermissionResult> {
993
- const sql = `
994
- with permission_checks as (
995
- select
996
- format('connect on database %I', current_database()) as permission_name,
997
- 'required' as status,
998
- has_database_privilege(current_user, current_database(), 'connect') as granted
999
-
1000
- union all
1001
-
1002
- select
1003
- 'pg_monitor role membership' as permission_name,
1004
- 'required' as status,
1005
- -- CASE guarantees evaluation order: pg_has_role() is only called if the
1006
- -- pg_monitor role exists, avoiding ERROR on PostgreSQL < 10 or when dropped.
1007
- case
1008
- when not exists (select from pg_roles where rolname = 'pg_monitor')
1009
- then false
1010
- else pg_has_role(current_user, 'pg_monitor', 'member')
1011
- end as granted
1012
-
1013
- union all
1014
-
1015
- select
1016
- 'select on pg_catalog.pg_index' as permission_name,
1017
- 'required' as status,
1018
- has_table_privilege(current_user, 'pg_catalog.pg_index', 'select') as granted
1019
-
1020
- union all
1021
-
1022
- select
1023
- 'postgres_ai schema exists' as permission_name,
1024
- 'optional' as status,
1025
- to_regnamespace('postgres_ai') is not null as granted
1026
-
1027
- union all
1028
-
1029
- select
1030
- 'usage on postgres_ai schema' as permission_name,
1031
- 'optional' as status,
1032
- case
1033
- when to_regnamespace('postgres_ai') is null then null
1034
- else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
1035
- end as granted
1036
-
1037
- union all
1038
-
1039
- select
1040
- 'postgres_ai.pg_statistic view exists' as permission_name,
1041
- 'optional' as status,
1042
- case
1043
- when to_regnamespace('postgres_ai') is null then null
1044
- when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
1045
- else to_regclass('postgres_ai.pg_statistic') is not null
1046
- end as granted
1047
-
1048
- union all
1049
-
1050
- select
1051
- 'select on postgres_ai.pg_statistic' as permission_name,
1052
- 'optional' as status,
1053
- case
1054
- when to_regnamespace('postgres_ai') is null then null
1055
- when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
1056
- when to_regclass('postgres_ai.pg_statistic') is null then null
1057
- else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
1058
- end as granted
1059
- )
1060
- select
1061
- permission_name,
1062
- status,
1063
- granted,
1064
- case
1065
- when status = 'required' and not coalesce(granted, false) then
1066
- case
1067
- when permission_name like 'connect%' then
1068
- format('grant connect on database %I to %I;', current_database(), current_user)
1069
- when permission_name = 'pg_monitor role membership' then
1070
- format('grant pg_monitor to %I;', current_user)
1071
- when permission_name like 'select on pg_catalog.pg_index' then
1072
- format('grant select on pg_catalog.pg_index to %I;', current_user)
1073
- end
1074
- when permission_name = 'postgres_ai schema exists' and granted = false then
1075
- '-- run postgresai prepare-db or create the postgres_ai schema and pg_statistic view manually'
1076
- when permission_name = 'usage on postgres_ai schema' and granted = false then
1077
- format('grant usage on schema postgres_ai to %I;', current_user)
1078
- when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
1079
- '-- create postgres_ai.pg_statistic view (see setup script)'
1080
- when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
1081
- format('grant select on postgres_ai.pg_statistic to %I;', current_user)
1082
- else null
1083
- end as fix_command
1084
- from permission_checks
1085
- order by
1086
- case status when 'required' then 1 else 2 end,
1087
- permission_name;
1088
- `;
1089
-
1090
- const res = await client.query(sql);
1091
- const rows: PermissionCheckRow[] = res.rows;
1092
-
1093
- // Required: treat null (skipped) as not-granted — fail safe.
1094
- // Optional: only explicit false counts as missing; null means the check was
1095
- // skipped (e.g., view doesn't exist) and is not actionable.
1096
- const missingRequired = rows.filter((r) => r.status === "required" && r.granted !== true);
1097
- const missingOptional = rows.filter((r) => r.status === "optional" && r.granted === false);
1098
-
1099
- return {
1100
- ok: missingRequired.length === 0,
1101
- rows,
1102
- missingRequired,
1103
- missingOptional,
1104
- };
1105
- }
1106
-
1107
- /**
1108
- * Format permission check results into user-facing error/warning lines.
1109
- *
1110
- * @returns An object with `warnings` (for optional misses), `errors` (for
1111
- * required misses including fix SQL), and `failed` (whether required
1112
- * permissions are missing).
1113
- */
1114
- export function formatPermissionCheckMessages(result: PreflightPermissionResult): {
1115
- failed: boolean;
1116
- warnings: string[];
1117
- errors: string[];
1118
- } {
1119
- const warnings: string[] = [];
1120
- const errors: string[] = [];
1121
-
1122
- for (const row of result.missingOptional) {
1123
- if (row.permission_name === "postgres_ai schema exists") {
1124
- warnings.push(
1125
- "Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them."
1126
- );
1127
- continue;
1128
- }
1129
- const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
1130
- warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
1131
- }
1132
-
1133
- if (!result.ok) {
1134
- errors.push("Error: the database user is missing required permissions.\n");
1135
- errors.push("Missing permissions:");
1136
- for (const row of result.missingRequired) {
1137
- errors.push(` - ${row.permission_name}`);
1138
- }
1139
- const fixes = result.missingRequired
1140
- .map((r) => r.fix_command)
1141
- .filter(Boolean);
1142
- if (fixes.length > 0) {
1143
- errors.push("\nTo fix, run the following as a superuser:\n");
1144
- for (const fix of fixes) {
1145
- errors.push(` ${fix}`);
1146
- }
1147
- }
1148
- errors.push("\nAlternatively, run 'postgresai prepare-db' to set up permissions automatically.");
1149
- }
1150
-
1151
- return { failed: !result.ok, warnings, errors };
1152
- }