postgresai 0.14.0-beta.2 → 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 +53 -45
  2. package/bin/postgres-ai.ts +953 -353
  3. package/bun.lock +258 -0
  4. package/bunfig.toml +11 -0
  5. package/dist/bin/postgres-ai.js +27868 -1781
  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 +283 -158
  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 -77
  42. package/dist/lib/init.d.ts.map +0 -1
  43. package/dist/lib/init.js +0 -550
  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
@@ -1,4 +1,3 @@
1
- import * as readline from "readline";
2
1
  import { randomBytes } from "crypto";
3
2
  import { URL } from "url";
4
3
  import type { ConnectionOptions as TlsConnectionOptions } from "tls";
@@ -18,11 +17,135 @@ export type PgClientConfig = {
18
17
  ssl?: boolean | TlsConnectionOptions;
19
18
  };
20
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
+
21
54
  export type AdminConnection = {
22
55
  clientConfig: PgClientConfig;
23
56
  display: string;
57
+ /** True if SSL fallback is enabled (try SSL first, fall back to non-SSL on failure). */
58
+ sslFallbackEnabled?: boolean;
24
59
  };
25
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
+
26
149
  export type InitStep = {
27
150
  name: string;
28
151
  sql: string;
@@ -36,13 +159,21 @@ export type InitPlan = {
36
159
  steps: InitStep[];
37
160
  };
38
161
 
39
- function packageRootDirFromCompiled(): string {
40
- // dist/lib/init.js -> <pkg>/dist/lib ; package root is ../..
41
- return path.resolve(__dirname, "..", "..");
42
- }
43
-
44
162
  function sqlDir(): string {
45
- 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(", ")}`);
46
177
  }
47
178
 
48
179
  function loadSqlTemplate(filename: string): string {
@@ -143,6 +274,7 @@ function tokenizeConninfo(input: string): string[] {
143
274
  export function parseLibpqConninfo(input: string): PgClientConfig {
144
275
  const tokens = tokenizeConninfo(input);
145
276
  const cfg: PgClientConfig = {};
277
+ let sslmode: string | undefined;
146
278
 
147
279
  for (const t of tokens) {
148
280
  const eq = t.indexOf("=");
@@ -171,12 +303,20 @@ export function parseLibpqConninfo(input: string): PgClientConfig {
171
303
  case "database":
172
304
  cfg.database = val;
173
305
  break;
174
- // ignore everything else (sslmode, options, application_name, etc.)
306
+ case "sslmode":
307
+ sslmode = val;
308
+ break;
309
+ // ignore everything else (options, application_name, etc.)
175
310
  default:
176
311
  break;
177
312
  }
178
313
  }
179
314
 
315
+ // Apply SSL configuration based on sslmode
316
+ if (sslmode) {
317
+ cfg.ssl = sslModeToConfig(sslmode);
318
+ }
319
+
180
320
  return cfg;
181
321
  }
182
322
 
@@ -203,6 +343,9 @@ export function resolveAdminConnection(opts: {
203
343
  const conn = (opts.conn || "").trim();
204
344
  const dbUrlFlag = (opts.dbUrlFlag || "").trim();
205
345
 
346
+ // Resolve explicit SSL setting from environment (undefined = auto-detect)
347
+ const explicitSsl = process.env.PGSSLMODE;
348
+
206
349
  // NOTE: passwords alone (PGPASSWORD / --admin-password) do NOT constitute a connection.
207
350
  // We require at least some connection addressing (host/port/user/db) if no positional arg / --db-url is provided.
208
351
  const hasConnDetails = !!(opts.host || opts.port || opts.username || opts.dbname);
@@ -214,28 +357,45 @@ export function resolveAdminConnection(opts: {
214
357
  if (conn || dbUrlFlag) {
215
358
  const v = conn || dbUrlFlag;
216
359
  if (isLikelyUri(v)) {
217
- 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
+ };
218
377
  }
219
378
  // libpq conninfo (dbname=... host=...)
220
379
  const cfg = parseLibpqConninfo(v);
221
380
  if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
222
- 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
+ };
223
394
  }
224
395
 
225
396
  if (!hasConnDetails) {
226
- throw new Error(
227
- [
228
- "Connection is required.",
229
- "",
230
- "Examples:",
231
- " postgresai init postgresql://admin@host:5432/dbname",
232
- " postgresai init \"dbname=dbname host=host user=admin\"",
233
- " postgresai init -h host -p 5432 -U admin -d dbname",
234
- "",
235
- "Admin password:",
236
- " --admin-password <password> (or set PGPASSWORD)",
237
- ].join("\n")
238
- );
397
+ // Keep this message short: the CLI prints full help (including examples) on this error.
398
+ throw new Error("Connection is required.");
239
399
  }
240
400
 
241
401
  const cfg: PgClientConfig = {};
@@ -251,73 +411,15 @@ export function resolveAdminConnection(opts: {
251
411
  if (opts.dbname) cfg.database = opts.dbname;
252
412
  if (opts.adminPassword) cfg.password = opts.adminPassword;
253
413
  if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
254
- return { clientConfig: cfg, display: describePgConfig(cfg) };
255
- }
256
-
257
- export async function promptHidden(prompt: string): Promise<string> {
258
- // Implement our own hidden input reader so:
259
- // - prompt text is visible
260
- // - only user input is masked
261
- // - we don't rely on non-public readline internals
262
- if (!process.stdin.isTTY) {
263
- throw new Error("Cannot prompt for password in non-interactive mode");
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 };
264
419
  }
265
-
266
- const stdin = process.stdin;
267
- const stdout = process.stdout as NodeJS.WriteStream;
268
-
269
- stdout.write(prompt);
270
-
271
- return await new Promise<string>((resolve, reject) => {
272
- let value = "";
273
-
274
- const cleanup = () => {
275
- try {
276
- stdin.setRawMode(false);
277
- } catch {
278
- // ignore
279
- }
280
- stdin.removeListener("keypress", onKeypress);
281
- };
282
-
283
- const onKeypress = (str: string, key: any) => {
284
- if (key?.ctrl && key?.name === "c") {
285
- stdout.write("\n");
286
- cleanup();
287
- reject(new Error("Cancelled"));
288
- return;
289
- }
290
-
291
- if (key?.name === "return" || key?.name === "enter") {
292
- stdout.write("\n");
293
- cleanup();
294
- resolve(value);
295
- return;
296
- }
297
-
298
- if (key?.name === "backspace") {
299
- if (value.length > 0) {
300
- value = value.slice(0, -1);
301
- // Erase one mask char.
302
- stdout.write("\b \b");
303
- }
304
- return;
305
- }
306
-
307
- // Ignore other control keys.
308
- if (key?.ctrl || key?.meta) return;
309
-
310
- if (typeof str === "string" && str.length > 0) {
311
- value += str;
312
- stdout.write("*");
313
- }
314
- };
315
-
316
- readline.emitKeypressEvents(stdin);
317
- stdin.setRawMode(true);
318
- stdin.on("keypress", onKeypress);
319
- stdin.resume();
320
- });
420
+ // Default: try SSL with fallback (sslmode=prefer behavior)
421
+ cfg.ssl = { rejectUnauthorized: false };
422
+ return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: true };
321
423
  }
322
424
 
323
425
  function generateMonitoringPassword(): string {
@@ -333,7 +435,6 @@ function generateMonitoringPassword(): string {
333
435
  export async function resolveMonitoringPassword(opts: {
334
436
  passwordFlag?: string;
335
437
  passwordEnv?: string;
336
- prompt?: (prompt: string) => Promise<string>;
337
438
  monitoringUser: string;
338
439
  }): Promise<{ password: string; generated: boolean }> {
339
440
  const fromFlag = (opts.passwordFlag || "").trim();
@@ -392,6 +493,12 @@ end $$;`;
392
493
  sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
393
494
  });
394
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
+
395
502
  if (params.includeOptionalPermissions) {
396
503
  steps.push(
397
504
  {
@@ -418,78 +525,70 @@ export async function applyInitPlan(params: {
418
525
  const applied: string[] = [];
419
526
  const skippedOptional: string[] = [];
420
527
 
421
- // Apply non-optional steps in a single transaction.
422
- await params.client.query("begin;");
423
- try {
424
- 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.
425
536
  try {
426
- await params.client.query(step.sql, step.params as any);
427
- applied.push(step.name);
428
- } catch (e) {
429
- const msg = e instanceof Error ? e.message : String(e);
430
- const errAny = e as any;
431
- const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
432
- // Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
433
- const pgErrorFields = [
434
- "code",
435
- "detail",
436
- "hint",
437
- "position",
438
- "internalPosition",
439
- "internalQuery",
440
- "where",
441
- "schema",
442
- "table",
443
- "column",
444
- "dataType",
445
- "constraint",
446
- "file",
447
- "line",
448
- "routine",
449
- ] as const;
450
- if (errAny && typeof errAny === "object") {
451
- for (const field of pgErrorFields) {
452
- if (errAny[field] !== undefined) wrapped[field] = errAny[field];
453
- }
454
- }
455
- if (e instanceof Error && e.stack) {
456
- wrapped.stack = e.stack;
457
- }
458
- throw wrapped;
537
+ await params.client.query("rollback;");
538
+ } catch {
539
+ // ignore
459
540
  }
541
+ throw e;
460
542
  }
461
- await params.client.query("commit;");
462
- } catch (e) {
463
- // 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)) {
464
547
  try {
465
- await params.client.query("rollback;");
466
- } catch {
467
- // 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;
468
581
  }
469
- throw e;
470
582
  }
471
583
 
472
- // 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)
473
585
  for (const step of params.plan.steps.filter((s) => s.optional)) {
474
586
  try {
475
- // Run each optional step in its own mini-transaction to avoid partial application.
476
- await params.client.query("begin;");
477
- try {
478
- await params.client.query(step.sql, step.params as any);
479
- await params.client.query("commit;");
480
- applied.push(step.name);
481
- } catch {
482
- try {
483
- await params.client.query("rollback;");
484
- } catch {
485
- // ignore rollback errors
486
- }
487
- skippedOptional.push(step.name);
488
- // best-effort: ignore
489
- }
587
+ await executeStep(step);
588
+ applied.push(step.name);
490
589
  } catch {
491
- // If we can't even begin/commit, treat as skipped.
492
590
  skippedOptional.push(step.name);
591
+ // best-effort: ignore
493
592
  }
494
593
  }
495
594
 
@@ -549,16 +648,25 @@ export async function verifyInitSetup(params: {
549
648
  missingRequired.push("SELECT on pg_catalog.pg_index");
550
649
  }
551
650
 
552
- 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");
553
661
  if (!viewExistsRes.rows?.[0]?.ok) {
554
- missingRequired.push("view public.pg_statistic exists");
662
+ missingRequired.push("view postgres_ai.pg_statistic exists");
555
663
  } else {
556
664
  const viewPrivRes = await params.client.query(
557
- "select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
665
+ "select has_table_privilege($1, 'postgres_ai.pg_statistic', 'SELECT') as ok",
558
666
  [role]
559
667
  );
560
668
  if (!viewPrivRes.rows?.[0]?.ok) {
561
- missingRequired.push("SELECT on view public.pg_statistic");
669
+ missingRequired.push("SELECT on view postgres_ai.pg_statistic");
562
670
  }
563
671
  }
564
672
 
@@ -576,13 +684,30 @@ export async function verifyInitSetup(params: {
576
684
  if (typeof spLine !== "string" || !spLine) {
577
685
  missingRequired.push("role search_path is set");
578
686
  } else {
579
- // 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.
580
688
  const sp = spLine.toLowerCase();
581
- if (!sp.includes("public") || !sp.includes("pg_catalog")) {
582
- 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");
583
691
  }
584
692
  }
585
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
+
586
711
  if (params.includeOptionalPermissions) {
587
712
  // Optional RDS/Aurora extras
588
713
  {