postgresai 0.14.0-beta.3 → 0.14.0-beta.4

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 (63) hide show
  1. package/README.md +45 -45
  2. package/bin/postgres-ai.ts +946 -336
  3. package/bun.lock +258 -0
  4. package/bunfig.toml +11 -0
  5. package/dist/bin/postgres-ai.js +27868 -1771
  6. package/lib/auth-server.ts +124 -106
  7. package/lib/checkup-api.ts +386 -0
  8. package/lib/checkup.ts +1327 -0
  9. package/lib/config.ts +3 -0
  10. package/lib/init.ts +282 -78
  11. package/lib/issues.ts +86 -195
  12. package/lib/mcp-server.ts +6 -17
  13. package/lib/metrics-embedded.ts +79 -0
  14. package/lib/metrics-loader.ts +127 -0
  15. package/lib/util.ts +61 -0
  16. package/package.json +18 -10
  17. package/packages/postgres-ai/README.md +26 -0
  18. package/packages/postgres-ai/bin/postgres-ai.js +27 -0
  19. package/packages/postgres-ai/package.json +27 -0
  20. package/scripts/embed-metrics.ts +154 -0
  21. package/sql/02.permissions.sql +9 -5
  22. package/sql/05.helpers.sql +415 -0
  23. package/test/checkup.integration.test.ts +273 -0
  24. package/test/checkup.test.ts +890 -0
  25. package/test/init.integration.test.ts +399 -0
  26. package/test/init.test.ts +345 -0
  27. package/test/schema-validation.test.ts +81 -0
  28. package/test/test-utils.ts +122 -0
  29. package/tsconfig.json +12 -20
  30. package/dist/bin/postgres-ai.d.ts +0 -3
  31. package/dist/bin/postgres-ai.d.ts.map +0 -1
  32. package/dist/bin/postgres-ai.js.map +0 -1
  33. package/dist/lib/auth-server.d.ts +0 -31
  34. package/dist/lib/auth-server.d.ts.map +0 -1
  35. package/dist/lib/auth-server.js +0 -263
  36. package/dist/lib/auth-server.js.map +0 -1
  37. package/dist/lib/config.d.ts +0 -45
  38. package/dist/lib/config.d.ts.map +0 -1
  39. package/dist/lib/config.js +0 -181
  40. package/dist/lib/config.js.map +0 -1
  41. package/dist/lib/init.d.ts +0 -75
  42. package/dist/lib/init.d.ts.map +0 -1
  43. package/dist/lib/init.js +0 -482
  44. package/dist/lib/init.js.map +0 -1
  45. package/dist/lib/issues.d.ts +0 -75
  46. package/dist/lib/issues.d.ts.map +0 -1
  47. package/dist/lib/issues.js +0 -336
  48. package/dist/lib/issues.js.map +0 -1
  49. package/dist/lib/mcp-server.d.ts +0 -9
  50. package/dist/lib/mcp-server.d.ts.map +0 -1
  51. package/dist/lib/mcp-server.js +0 -168
  52. package/dist/lib/mcp-server.js.map +0 -1
  53. package/dist/lib/pkce.d.ts +0 -32
  54. package/dist/lib/pkce.d.ts.map +0 -1
  55. package/dist/lib/pkce.js +0 -101
  56. package/dist/lib/pkce.js.map +0 -1
  57. package/dist/lib/util.d.ts +0 -27
  58. package/dist/lib/util.d.ts.map +0 -1
  59. package/dist/lib/util.js +0 -46
  60. package/dist/lib/util.js.map +0 -1
  61. package/dist/package.json +0 -46
  62. package/test/init.integration.test.cjs +0 -382
  63. package/test/init.test.cjs +0 -323
package/lib/config.ts CHANGED
@@ -9,6 +9,7 @@ export interface Config {
9
9
  apiKey: string | null;
10
10
  baseUrl: string | null;
11
11
  orgId: number | null;
12
+ defaultProject: string | null;
12
13
  }
13
14
 
14
15
  /**
@@ -46,6 +47,7 @@ export function readConfig(): Config {
46
47
  apiKey: null,
47
48
  baseUrl: null,
48
49
  orgId: null,
50
+ defaultProject: null,
49
51
  };
50
52
 
51
53
  // Try user-level config first
@@ -57,6 +59,7 @@ export function readConfig(): Config {
57
59
  config.apiKey = parsed.apiKey || null;
58
60
  config.baseUrl = parsed.baseUrl || null;
59
61
  config.orgId = parsed.orgId || null;
62
+ config.defaultProject = parsed.defaultProject || null;
60
63
  return config;
61
64
  } catch (err) {
62
65
  const message = err instanceof Error ? err.message : String(err);
package/lib/init.ts CHANGED
@@ -17,11 +17,135 @@ export type PgClientConfig = {
17
17
  ssl?: boolean | TlsConnectionOptions;
18
18
  };
19
19
 
20
+ /**
21
+ * Convert PostgreSQL sslmode to node-postgres ssl config.
22
+ */
23
+ function sslModeToConfig(mode: string): boolean | TlsConnectionOptions {
24
+ if (mode.toLowerCase() === "disable") return false;
25
+ if (mode.toLowerCase() === "verify-full" || mode.toLowerCase() === "verify-ca") return true;
26
+ // For require/prefer/allow: encrypt without certificate verification
27
+ return { rejectUnauthorized: false };
28
+ }
29
+
30
+ /** Extract sslmode from a PostgreSQL connection URI. */
31
+ function extractSslModeFromUri(uri: string): string | undefined {
32
+ try {
33
+ return new URL(uri).searchParams.get("sslmode") ?? undefined;
34
+ } catch {
35
+ return uri.match(/[?&]sslmode=([^&]+)/i)?.[1];
36
+ }
37
+ }
38
+
39
+ /** Remove sslmode parameter from a PostgreSQL connection URI. */
40
+ function stripSslModeFromUri(uri: string): string {
41
+ try {
42
+ const u = new URL(uri);
43
+ u.searchParams.delete("sslmode");
44
+ return u.toString();
45
+ } catch {
46
+ // Fallback regex for malformed URIs
47
+ return uri
48
+ .replace(/[?&]sslmode=[^&]*/gi, "")
49
+ .replace(/\?&/, "?")
50
+ .replace(/\?$/, "");
51
+ }
52
+ }
53
+
20
54
  export type AdminConnection = {
21
55
  clientConfig: PgClientConfig;
22
56
  display: string;
57
+ /** True if SSL fallback is enabled (try SSL first, fall back to non-SSL on failure). */
58
+ sslFallbackEnabled?: boolean;
23
59
  };
24
60
 
61
+ /**
62
+ * Check if an error indicates SSL negotiation failed and fallback to non-SSL should be attempted.
63
+ * This mimics libpq's sslmode=prefer behavior.
64
+ *
65
+ * IMPORTANT: This should NOT match certificate errors (expired, invalid, self-signed)
66
+ * as those are real errors the user needs to fix, not negotiation failures.
67
+ */
68
+ function isSslNegotiationError(err: unknown): boolean {
69
+ if (!err || typeof err !== "object") return false;
70
+ const e = err as any;
71
+ const msg = typeof e.message === "string" ? e.message.toLowerCase() : "";
72
+ const code = typeof e.code === "string" ? e.code : "";
73
+
74
+ // Specific patterns that indicate server doesn't support SSL (should fallback)
75
+ const fallbackPatterns = [
76
+ "the server does not support ssl",
77
+ "ssl off",
78
+ "server does not support ssl connections",
79
+ ];
80
+
81
+ for (const pattern of fallbackPatterns) {
82
+ if (msg.includes(pattern)) return true;
83
+ }
84
+
85
+ // PostgreSQL error code 08P01 (protocol violation) during initial connection
86
+ // often indicates SSL negotiation mismatch, but only if the message suggests it
87
+ if (code === "08P01" && (msg.includes("ssl") || msg.includes("unsupported"))) {
88
+ return true;
89
+ }
90
+
91
+ return false;
92
+ }
93
+
94
+ /**
95
+ * Connect to PostgreSQL with sslmode=prefer-like behavior.
96
+ * If sslFallbackEnabled is true, tries SSL first, then falls back to non-SSL on failure.
97
+ */
98
+ export async function connectWithSslFallback(
99
+ ClientClass: new (config: PgClientConfig) => PgClient,
100
+ adminConn: AdminConnection,
101
+ verbose?: boolean
102
+ ): Promise<{ client: PgClient; usedSsl: boolean }> {
103
+ const tryConnect = async (config: PgClientConfig): Promise<PgClient> => {
104
+ const client = new ClientClass(config);
105
+ await client.connect();
106
+ return client;
107
+ };
108
+
109
+ // If SSL was explicitly set or no SSL configured, just try once
110
+ if (!adminConn.sslFallbackEnabled) {
111
+ const client = await tryConnect(adminConn.clientConfig);
112
+ return { client, usedSsl: !!adminConn.clientConfig.ssl };
113
+ }
114
+
115
+ // sslmode=prefer behavior: try SSL first, fallback to non-SSL
116
+ try {
117
+ const client = await tryConnect(adminConn.clientConfig);
118
+ return { client, usedSsl: true };
119
+ } catch (sslErr) {
120
+ if (!isSslNegotiationError(sslErr)) {
121
+ // Not an SSL error, don't retry
122
+ throw sslErr;
123
+ }
124
+
125
+ if (verbose) {
126
+ console.log("SSL connection failed, retrying without SSL...");
127
+ }
128
+
129
+ // Retry without SSL
130
+ const noSslConfig: PgClientConfig = { ...adminConn.clientConfig, ssl: false };
131
+ try {
132
+ const client = await tryConnect(noSslConfig);
133
+ return { client, usedSsl: false };
134
+ } catch (noSslErr) {
135
+ // If non-SSL also fails, check if it's "SSL required" - throw that instead
136
+ if (isSslNegotiationError(noSslErr)) {
137
+ const msg = (noSslErr as any)?.message || "";
138
+ if (msg.toLowerCase().includes("ssl") && msg.toLowerCase().includes("required")) {
139
+ // Server requires SSL but SSL attempt failed - throw original SSL error
140
+ throw sslErr;
141
+ }
142
+ }
143
+ // Throw the non-SSL error (it's more relevant since SSL attempt also failed)
144
+ throw noSslErr;
145
+ }
146
+ }
147
+ }
148
+
25
149
  export type InitStep = {
26
150
  name: string;
27
151
  sql: string;
@@ -35,13 +159,21 @@ export type InitPlan = {
35
159
  steps: InitStep[];
36
160
  };
37
161
 
38
- function packageRootDirFromCompiled(): string {
39
- // dist/lib/init.js -> <pkg>/dist/lib ; package root is ../..
40
- return path.resolve(__dirname, "..", "..");
41
- }
42
-
43
162
  function sqlDir(): string {
44
- return path.join(packageRootDirFromCompiled(), "sql");
163
+ // Handle both development and production paths
164
+ // Development: lib/init.ts -> ../sql
165
+ // Production (bundled): dist/bin/postgres-ai.js -> ../sql (copied during build)
166
+ const candidates = [
167
+ path.resolve(__dirname, "..", "sql"), // bundled: dist/bin -> dist/sql
168
+ path.resolve(__dirname, "..", "..", "sql"), // dev from lib: lib -> ../sql
169
+ ];
170
+
171
+ for (const candidate of candidates) {
172
+ if (fs.existsSync(candidate)) {
173
+ return candidate;
174
+ }
175
+ }
176
+ throw new Error(`SQL directory not found. Searched: ${candidates.join(", ")}`);
45
177
  }
46
178
 
47
179
  function loadSqlTemplate(filename: string): string {
@@ -142,6 +274,7 @@ function tokenizeConninfo(input: string): string[] {
142
274
  export function parseLibpqConninfo(input: string): PgClientConfig {
143
275
  const tokens = tokenizeConninfo(input);
144
276
  const cfg: PgClientConfig = {};
277
+ let sslmode: string | undefined;
145
278
 
146
279
  for (const t of tokens) {
147
280
  const eq = t.indexOf("=");
@@ -170,12 +303,20 @@ export function parseLibpqConninfo(input: string): PgClientConfig {
170
303
  case "database":
171
304
  cfg.database = val;
172
305
  break;
173
- // ignore everything else (sslmode, options, application_name, etc.)
306
+ case "sslmode":
307
+ sslmode = val;
308
+ break;
309
+ // ignore everything else (options, application_name, etc.)
174
310
  default:
175
311
  break;
176
312
  }
177
313
  }
178
314
 
315
+ // Apply SSL configuration based on sslmode
316
+ if (sslmode) {
317
+ cfg.ssl = sslModeToConfig(sslmode);
318
+ }
319
+
179
320
  return cfg;
180
321
  }
181
322
 
@@ -202,6 +343,9 @@ export function resolveAdminConnection(opts: {
202
343
  const conn = (opts.conn || "").trim();
203
344
  const dbUrlFlag = (opts.dbUrlFlag || "").trim();
204
345
 
346
+ // Resolve explicit SSL setting from environment (undefined = auto-detect)
347
+ const explicitSsl = process.env.PGSSLMODE;
348
+
205
349
  // NOTE: passwords alone (PGPASSWORD / --admin-password) do NOT constitute a connection.
206
350
  // We require at least some connection addressing (host/port/user/db) if no positional arg / --db-url is provided.
207
351
  const hasConnDetails = !!(opts.host || opts.port || opts.username || opts.dbname);
@@ -213,12 +357,40 @@ export function resolveAdminConnection(opts: {
213
357
  if (conn || dbUrlFlag) {
214
358
  const v = conn || dbUrlFlag;
215
359
  if (isLikelyUri(v)) {
216
- return { clientConfig: { connectionString: v }, display: maskConnectionString(v) };
360
+ const urlSslMode = extractSslModeFromUri(v);
361
+ const effectiveSslMode = explicitSsl || urlSslMode;
362
+ // SSL priority: PGSSLMODE env > URL param > auto (sslmode=prefer behavior)
363
+ const sslConfig = effectiveSslMode
364
+ ? sslModeToConfig(effectiveSslMode)
365
+ : { rejectUnauthorized: false }; // Default: try SSL (with fallback)
366
+ // Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
367
+ const shouldFallback = !effectiveSslMode ||
368
+ effectiveSslMode.toLowerCase() === "prefer" ||
369
+ effectiveSslMode.toLowerCase() === "allow";
370
+ // Strip sslmode from URI so pg uses our ssl config object instead
371
+ const cleanUri = stripSslModeFromUri(v);
372
+ return {
373
+ clientConfig: { connectionString: cleanUri, ssl: sslConfig },
374
+ display: maskConnectionString(v),
375
+ sslFallbackEnabled: shouldFallback,
376
+ };
217
377
  }
218
378
  // libpq conninfo (dbname=... host=...)
219
379
  const cfg = parseLibpqConninfo(v);
220
380
  if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
221
- return { clientConfig: cfg, display: describePgConfig(cfg) };
381
+ const cfgHadSsl = cfg.ssl !== undefined;
382
+ if (cfg.ssl === undefined) {
383
+ if (explicitSsl) cfg.ssl = sslModeToConfig(explicitSsl);
384
+ else cfg.ssl = { rejectUnauthorized: false }; // Default: try SSL (with fallback)
385
+ }
386
+ // Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
387
+ const shouldFallback = (!explicitSsl && !cfgHadSsl) ||
388
+ (!!explicitSsl && (explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow"));
389
+ return {
390
+ clientConfig: cfg,
391
+ display: describePgConfig(cfg),
392
+ sslFallbackEnabled: shouldFallback,
393
+ };
222
394
  }
223
395
 
224
396
  if (!hasConnDetails) {
@@ -239,7 +411,15 @@ export function resolveAdminConnection(opts: {
239
411
  if (opts.dbname) cfg.database = opts.dbname;
240
412
  if (opts.adminPassword) cfg.password = opts.adminPassword;
241
413
  if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
242
- return { clientConfig: cfg, display: describePgConfig(cfg) };
414
+ if (explicitSsl) {
415
+ cfg.ssl = sslModeToConfig(explicitSsl);
416
+ // Enable fallback for explicit "prefer"/"allow"
417
+ const shouldFallback = explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow";
418
+ return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: shouldFallback };
419
+ }
420
+ // Default: try SSL with fallback (sslmode=prefer behavior)
421
+ cfg.ssl = { rejectUnauthorized: false };
422
+ return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: true };
243
423
  }
244
424
 
245
425
  function generateMonitoringPassword(): string {
@@ -313,6 +493,12 @@ end $$;`;
313
493
  sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
314
494
  });
315
495
 
496
+ // Helper functions (SECURITY DEFINER) for plan analysis and table info
497
+ steps.push({
498
+ name: "05.helpers",
499
+ sql: applyTemplate(loadSqlTemplate("05.helpers.sql"), vars),
500
+ });
501
+
316
502
  if (params.includeOptionalPermissions) {
317
503
  steps.push(
318
504
  {
@@ -339,78 +525,70 @@ export async function applyInitPlan(params: {
339
525
  const applied: string[] = [];
340
526
  const skippedOptional: string[] = [];
341
527
 
342
- // Apply non-optional steps in a single transaction.
343
- await params.client.query("begin;");
344
- try {
345
- for (const step of params.plan.steps.filter((s) => !s.optional)) {
528
+ // Helper to wrap a step execution in begin/commit
529
+ const executeStep = async (step: InitStep): Promise<void> => {
530
+ await params.client.query("begin;");
531
+ try {
532
+ await params.client.query(step.sql, step.params as any);
533
+ await params.client.query("commit;");
534
+ } catch (e) {
535
+ // Rollback errors should never mask the original failure.
346
536
  try {
347
- await params.client.query(step.sql, step.params as any);
348
- applied.push(step.name);
349
- } catch (e) {
350
- const msg = e instanceof Error ? e.message : String(e);
351
- const errAny = e as any;
352
- const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
353
- // Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
354
- const pgErrorFields = [
355
- "code",
356
- "detail",
357
- "hint",
358
- "position",
359
- "internalPosition",
360
- "internalQuery",
361
- "where",
362
- "schema",
363
- "table",
364
- "column",
365
- "dataType",
366
- "constraint",
367
- "file",
368
- "line",
369
- "routine",
370
- ] as const;
371
- if (errAny && typeof errAny === "object") {
372
- for (const field of pgErrorFields) {
373
- if (errAny[field] !== undefined) wrapped[field] = errAny[field];
374
- }
375
- }
376
- if (e instanceof Error && e.stack) {
377
- wrapped.stack = e.stack;
378
- }
379
- throw wrapped;
537
+ await params.client.query("rollback;");
538
+ } catch {
539
+ // ignore
380
540
  }
541
+ throw e;
381
542
  }
382
- await params.client.query("commit;");
383
- } catch (e) {
384
- // Rollback errors should never mask the original failure.
543
+ };
544
+
545
+ // Apply non-optional steps, each in its own transaction
546
+ for (const step of params.plan.steps.filter((s) => !s.optional)) {
385
547
  try {
386
- await params.client.query("rollback;");
387
- } catch {
388
- // ignore
548
+ await executeStep(step);
549
+ applied.push(step.name);
550
+ } catch (e) {
551
+ const msg = e instanceof Error ? e.message : String(e);
552
+ const errAny = e as any;
553
+ const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
554
+ // Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
555
+ const pgErrorFields = [
556
+ "code",
557
+ "detail",
558
+ "hint",
559
+ "position",
560
+ "internalPosition",
561
+ "internalQuery",
562
+ "where",
563
+ "schema",
564
+ "table",
565
+ "column",
566
+ "dataType",
567
+ "constraint",
568
+ "file",
569
+ "line",
570
+ "routine",
571
+ ] as const;
572
+ if (errAny && typeof errAny === "object") {
573
+ for (const field of pgErrorFields) {
574
+ if (errAny[field] !== undefined) wrapped[field] = errAny[field];
575
+ }
576
+ }
577
+ if (e instanceof Error && e.stack) {
578
+ wrapped.stack = e.stack;
579
+ }
580
+ throw wrapped;
389
581
  }
390
- throw e;
391
582
  }
392
583
 
393
- // Apply optional steps outside of the transaction so a failure doesn't abort everything.
584
+ // Apply optional steps, each in its own transaction (failure doesn't abort)
394
585
  for (const step of params.plan.steps.filter((s) => s.optional)) {
395
586
  try {
396
- // Run each optional step in its own mini-transaction to avoid partial application.
397
- await params.client.query("begin;");
398
- try {
399
- await params.client.query(step.sql, step.params as any);
400
- await params.client.query("commit;");
401
- applied.push(step.name);
402
- } catch {
403
- try {
404
- await params.client.query("rollback;");
405
- } catch {
406
- // ignore rollback errors
407
- }
408
- skippedOptional.push(step.name);
409
- // best-effort: ignore
410
- }
587
+ await executeStep(step);
588
+ applied.push(step.name);
411
589
  } catch {
412
- // If we can't even begin/commit, treat as skipped.
413
590
  skippedOptional.push(step.name);
591
+ // best-effort: ignore
414
592
  }
415
593
  }
416
594
 
@@ -470,16 +648,25 @@ export async function verifyInitSetup(params: {
470
648
  missingRequired.push("SELECT on pg_catalog.pg_index");
471
649
  }
472
650
 
473
- const viewExistsRes = await params.client.query("select to_regclass('public.pg_statistic') is not null as ok");
651
+ // Check postgres_ai schema exists and is usable
652
+ const schemaExistsRes = await params.client.query(
653
+ "select has_schema_privilege($1, 'postgres_ai', 'USAGE') as ok",
654
+ [role]
655
+ );
656
+ if (!schemaExistsRes.rows?.[0]?.ok) {
657
+ missingRequired.push("USAGE on schema postgres_ai");
658
+ }
659
+
660
+ const viewExistsRes = await params.client.query("select to_regclass('postgres_ai.pg_statistic') is not null as ok");
474
661
  if (!viewExistsRes.rows?.[0]?.ok) {
475
- missingRequired.push("view public.pg_statistic exists");
662
+ missingRequired.push("view postgres_ai.pg_statistic exists");
476
663
  } else {
477
664
  const viewPrivRes = await params.client.query(
478
- "select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
665
+ "select has_table_privilege($1, 'postgres_ai.pg_statistic', 'SELECT') as ok",
479
666
  [role]
480
667
  );
481
668
  if (!viewPrivRes.rows?.[0]?.ok) {
482
- missingRequired.push("SELECT on view public.pg_statistic");
669
+ missingRequired.push("SELECT on view postgres_ai.pg_statistic");
483
670
  }
484
671
  }
485
672
 
@@ -497,13 +684,30 @@ export async function verifyInitSetup(params: {
497
684
  if (typeof spLine !== "string" || !spLine) {
498
685
  missingRequired.push("role search_path is set");
499
686
  } else {
500
- // We accept any ordering as long as public and pg_catalog are included.
687
+ // We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
501
688
  const sp = spLine.toLowerCase();
502
- if (!sp.includes("public") || !sp.includes("pg_catalog")) {
503
- missingRequired.push("role search_path includes public and pg_catalog");
689
+ if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
690
+ missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
504
691
  }
505
692
  }
506
693
 
694
+ // Check for helper functions
695
+ const explainFnRes = await params.client.query(
696
+ "select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok",
697
+ [role]
698
+ );
699
+ if (!explainFnRes.rows?.[0]?.ok) {
700
+ missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
701
+ }
702
+
703
+ const tableDescribeFnRes = await params.client.query(
704
+ "select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok",
705
+ [role]
706
+ );
707
+ if (!tableDescribeFnRes.rows?.[0]?.ok) {
708
+ missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
709
+ }
710
+
507
711
  if (params.includeOptionalPermissions) {
508
712
  // Optional RDS/Aurora extras
509
713
  {