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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +161 -61
  2. package/bin/postgres-ai.ts +2596 -428
  3. package/bun.lock +258 -0
  4. package/bunfig.toml +20 -0
  5. package/dist/bin/postgres-ai.js +31218 -1575
  6. package/dist/sql/01.role.sql +16 -0
  7. package/dist/sql/02.extensions.sql +8 -0
  8. package/dist/sql/03.permissions.sql +38 -0
  9. package/dist/sql/04.optional_rds.sql +6 -0
  10. package/dist/sql/05.optional_self_managed.sql +8 -0
  11. package/dist/sql/06.helpers.sql +439 -0
  12. package/dist/sql/sql/01.role.sql +16 -0
  13. package/dist/sql/sql/02.extensions.sql +8 -0
  14. package/dist/sql/sql/03.permissions.sql +38 -0
  15. package/dist/sql/sql/04.optional_rds.sql +6 -0
  16. package/dist/sql/sql/05.optional_self_managed.sql +8 -0
  17. package/dist/sql/sql/06.helpers.sql +439 -0
  18. package/dist/sql/sql/uninit/01.helpers.sql +5 -0
  19. package/dist/sql/sql/uninit/02.permissions.sql +30 -0
  20. package/dist/sql/sql/uninit/03.role.sql +27 -0
  21. package/dist/sql/uninit/01.helpers.sql +5 -0
  22. package/dist/sql/uninit/02.permissions.sql +30 -0
  23. package/dist/sql/uninit/03.role.sql +27 -0
  24. package/lib/auth-server.ts +124 -106
  25. package/lib/checkup-api.ts +386 -0
  26. package/lib/checkup-dictionary.ts +113 -0
  27. package/lib/checkup.ts +1435 -0
  28. package/lib/config.ts +6 -3
  29. package/lib/init.ts +655 -189
  30. package/lib/issues.ts +848 -193
  31. package/lib/mcp-server.ts +391 -91
  32. package/lib/metrics-loader.ts +127 -0
  33. package/lib/supabase.ts +824 -0
  34. package/lib/util.ts +61 -0
  35. package/package.json +22 -10
  36. package/packages/postgres-ai/README.md +26 -0
  37. package/packages/postgres-ai/bin/postgres-ai.js +27 -0
  38. package/packages/postgres-ai/package.json +27 -0
  39. package/scripts/embed-checkup-dictionary.ts +106 -0
  40. package/scripts/embed-metrics.ts +154 -0
  41. package/sql/01.role.sql +16 -0
  42. package/sql/02.extensions.sql +8 -0
  43. package/sql/03.permissions.sql +38 -0
  44. package/sql/04.optional_rds.sql +6 -0
  45. package/sql/05.optional_self_managed.sql +8 -0
  46. package/sql/06.helpers.sql +439 -0
  47. package/sql/uninit/01.helpers.sql +5 -0
  48. package/sql/uninit/02.permissions.sql +30 -0
  49. package/sql/uninit/03.role.sql +27 -0
  50. package/test/auth.test.ts +258 -0
  51. package/test/checkup.integration.test.ts +321 -0
  52. package/test/checkup.test.ts +1116 -0
  53. package/test/config-consistency.test.ts +36 -0
  54. package/test/init.integration.test.ts +508 -0
  55. package/test/init.test.ts +916 -0
  56. package/test/issues.cli.test.ts +538 -0
  57. package/test/issues.test.ts +456 -0
  58. package/test/mcp-server.test.ts +1527 -0
  59. package/test/schema-validation.test.ts +81 -0
  60. package/test/supabase.test.ts +568 -0
  61. package/test/test-utils.ts +128 -0
  62. package/tsconfig.json +12 -20
  63. package/dist/bin/postgres-ai.d.ts +0 -3
  64. package/dist/bin/postgres-ai.d.ts.map +0 -1
  65. package/dist/bin/postgres-ai.js.map +0 -1
  66. package/dist/lib/auth-server.d.ts +0 -31
  67. package/dist/lib/auth-server.d.ts.map +0 -1
  68. package/dist/lib/auth-server.js +0 -263
  69. package/dist/lib/auth-server.js.map +0 -1
  70. package/dist/lib/config.d.ts +0 -45
  71. package/dist/lib/config.d.ts.map +0 -1
  72. package/dist/lib/config.js +0 -181
  73. package/dist/lib/config.js.map +0 -1
  74. package/dist/lib/init.d.ts +0 -64
  75. package/dist/lib/init.d.ts.map +0 -1
  76. package/dist/lib/init.js +0 -399
  77. package/dist/lib/init.js.map +0 -1
  78. package/dist/lib/issues.d.ts +0 -75
  79. package/dist/lib/issues.d.ts.map +0 -1
  80. package/dist/lib/issues.js +0 -336
  81. package/dist/lib/issues.js.map +0 -1
  82. package/dist/lib/mcp-server.d.ts +0 -9
  83. package/dist/lib/mcp-server.d.ts.map +0 -1
  84. package/dist/lib/mcp-server.js +0 -168
  85. package/dist/lib/mcp-server.js.map +0 -1
  86. package/dist/lib/pkce.d.ts +0 -32
  87. package/dist/lib/pkce.d.ts.map +0 -1
  88. package/dist/lib/pkce.js +0 -101
  89. package/dist/lib/pkce.js.map +0 -1
  90. package/dist/lib/util.d.ts +0 -27
  91. package/dist/lib/util.d.ts.map +0 -1
  92. package/dist/lib/util.js +0 -46
  93. package/dist/lib/util.js.map +0 -1
  94. package/dist/package.json +0 -46
  95. package/test/init.integration.test.cjs +0 -269
  96. package/test/init.test.cjs +0 -76
@@ -0,0 +1,824 @@
1
+ /**
2
+ * Supabase Management API client for database operations.
3
+ *
4
+ * This module provides an alternative to direct PostgreSQL connections by using
5
+ * the Supabase Management API to execute SQL queries.
6
+ *
7
+ * API Reference: https://supabase.com/docs/reference/api/introduction
8
+ * Endpoint: POST /v1/projects/{ref}/database/query
9
+ */
10
+
11
+ const SUPABASE_API_BASE = "https://api.supabase.com";
12
+
13
+ export type SupabaseConfig = {
14
+ /** Supabase project reference (e.g., "abc123xyz") */
15
+ projectRef: string;
16
+ /** Supabase Management API access token (Personal Access Token) */
17
+ accessToken: string;
18
+ };
19
+
20
+ /**
21
+ * PostgreSQL-compatible error structure.
22
+ * Mirrors the error fields from node-postgres for consistent error handling.
23
+ */
24
+ export type PgCompatibleError = Error & {
25
+ code?: string;
26
+ detail?: string;
27
+ hint?: string;
28
+ position?: string;
29
+ internalPosition?: string;
30
+ internalQuery?: string;
31
+ where?: string;
32
+ schema?: string;
33
+ table?: string;
34
+ column?: string;
35
+ dataType?: string;
36
+ constraint?: string;
37
+ file?: string;
38
+ line?: string;
39
+ routine?: string;
40
+ // Supabase-specific fields (mapped to pg-compatible structure)
41
+ supabaseErrorCode?: string;
42
+ httpStatus?: number;
43
+ };
44
+
45
+ /**
46
+ * Result from Supabase Management API query endpoint.
47
+ */
48
+ export type SupabaseQueryResult = {
49
+ rows: Record<string, unknown>[];
50
+ rowCount: number;
51
+ };
52
+
53
+ /**
54
+ * Raw response from Supabase Management API.
55
+ */
56
+ type SupabaseApiResponse = {
57
+ // Success case: array of rows
58
+ // Error case: { code, message, ... }
59
+ error?: {
60
+ code?: string;
61
+ message?: string;
62
+ details?: string;
63
+ hint?: string;
64
+ };
65
+ // The API returns the result directly (array) on success
66
+ } | Record<string, unknown>[];
67
+
68
+ /**
69
+ * Validate Supabase project reference format.
70
+ * Project refs are typically 20 lowercase alphanumeric characters.
71
+ */
72
+ function isValidProjectRef(ref: string): boolean {
73
+ // Supabase project refs are alphanumeric, typically 20 chars, lowercase
74
+ return /^[a-z0-9]{10,30}$/i.test(ref);
75
+ }
76
+
77
+ /**
78
+ * Supabase Management API client for executing SQL queries.
79
+ */
80
+ export class SupabaseClient {
81
+ private config: SupabaseConfig;
82
+
83
+ constructor(config: SupabaseConfig) {
84
+ if (!config.projectRef) {
85
+ throw new Error("Supabase project reference is required");
86
+ }
87
+ if (!config.accessToken) {
88
+ throw new Error("Supabase access token is required");
89
+ }
90
+ // Validate project ref format to prevent path traversal
91
+ if (!isValidProjectRef(config.projectRef)) {
92
+ throw new Error(`Invalid Supabase project reference format: "${config.projectRef}". Expected 10-30 alphanumeric characters.`);
93
+ }
94
+ this.config = config;
95
+ }
96
+
97
+ /**
98
+ * Execute a SQL query via the Supabase Management API.
99
+ *
100
+ * @param sql The SQL query to execute
101
+ * @param readOnly If true, uses read_only flag in API request (default: false for DDL/DML operations)
102
+ * @returns Query result with rows and rowCount (rowCount is array length for SELECT queries)
103
+ * @throws PgCompatibleError on failure
104
+ */
105
+ async query(sql: string, readOnly = false): Promise<SupabaseQueryResult> {
106
+ // URL-encode projectRef for safety (validated in constructor, but defense in depth)
107
+ const url = `${SUPABASE_API_BASE}/v1/projects/${encodeURIComponent(this.config.projectRef)}/database/query`;
108
+
109
+ const response = await fetch(url, {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ Authorization: `Bearer ${this.config.accessToken}`,
114
+ },
115
+ body: JSON.stringify({
116
+ query: sql,
117
+ read_only: readOnly,
118
+ }),
119
+ });
120
+
121
+ const body = await response.text();
122
+ let data: SupabaseApiResponse;
123
+
124
+ try {
125
+ data = JSON.parse(body);
126
+ } catch {
127
+ // If we can't parse JSON, create an error with the raw body
128
+ throw this.createPgError({
129
+ message: `Supabase API returned non-JSON response: ${body.slice(0, 200)}`,
130
+ httpStatus: response.status,
131
+ });
132
+ }
133
+
134
+ // Handle HTTP errors
135
+ if (!response.ok) {
136
+ throw this.parseApiError(data, response.status);
137
+ }
138
+
139
+ // Handle explicit error response
140
+ if (data && typeof data === "object" && "error" in data && data.error) {
141
+ throw this.parseApiError(data, response.status);
142
+ }
143
+
144
+ // Success: API returns array of rows directly
145
+ const rows = Array.isArray(data) ? data : [];
146
+ return {
147
+ rows: rows as Record<string, unknown>[],
148
+ rowCount: rows.length,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Test connection by executing a simple query.
154
+ */
155
+ async testConnection(): Promise<{ database: string; version: string }> {
156
+ const result = await this.query(
157
+ "SELECT current_database() as db, version() as version",
158
+ true
159
+ );
160
+ const row = result.rows[0] ?? {};
161
+ return {
162
+ database: String(row.db ?? ""),
163
+ version: String(row.version ?? ""),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Get current database name.
169
+ */
170
+ async getCurrentDatabase(): Promise<string> {
171
+ const result = await this.query("SELECT current_database() as db", true);
172
+ const row = result.rows[0] ?? {};
173
+ return String(row.db ?? "");
174
+ }
175
+
176
+ /**
177
+ * Parse Supabase API error and convert to PostgreSQL-compatible error.
178
+ */
179
+ private parseApiError(
180
+ data: SupabaseApiResponse,
181
+ httpStatus: number
182
+ ): PgCompatibleError {
183
+ // Handle different error formats from Supabase API
184
+ if (data && typeof data === "object" && !Array.isArray(data)) {
185
+ const errObj = "error" in data && data.error ? data.error : data;
186
+
187
+ // Check for PostgreSQL error embedded in the response
188
+ // Supabase forwards PostgreSQL errors with their original structure
189
+ const pgCode = this.extractPgErrorCode(errObj);
190
+ const message = this.extractErrorMessage(errObj);
191
+ const detail = this.extractField(errObj, ["details", "detail"]);
192
+ const hint = this.extractField(errObj, ["hint"]);
193
+
194
+ return this.createPgError({
195
+ message,
196
+ code: pgCode,
197
+ detail,
198
+ hint,
199
+ httpStatus,
200
+ supabaseErrorCode:
201
+ typeof errObj === "object" && errObj && "code" in errObj
202
+ ? String((errObj as Record<string, unknown>).code ?? "")
203
+ : undefined,
204
+ });
205
+ }
206
+
207
+ return this.createPgError({
208
+ message: `Supabase API error (HTTP ${httpStatus})`,
209
+ httpStatus,
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Extract PostgreSQL error code from various error formats.
215
+ * Supabase may return errors as:
216
+ * - { code: "42501", ... } (PostgreSQL error code)
217
+ * - { code: "PGRST...", ... } (PostgREST error code)
218
+ * - { error: { code: "...", ... } }
219
+ */
220
+ private extractPgErrorCode(errObj: unknown): string | undefined {
221
+ if (!errObj || typeof errObj !== "object") return undefined;
222
+
223
+ const obj = errObj as Record<string, unknown>;
224
+
225
+ // Direct code field
226
+ if (typeof obj.code === "string") {
227
+ const code = obj.code;
228
+ // PostgreSQL error codes are 5 characters (e.g., "42501")
229
+ if (/^\d{5}$/.test(code)) {
230
+ return code;
231
+ }
232
+ // Map common Supabase/PostgREST error codes to PostgreSQL equivalents
233
+ return this.mapSupabaseCodeToPg(code);
234
+ }
235
+
236
+ return undefined;
237
+ }
238
+
239
+ /**
240
+ * Map Supabase/PostgREST error codes to PostgreSQL equivalents.
241
+ */
242
+ private mapSupabaseCodeToPg(code: string): string | undefined {
243
+ // PostgREST error codes: https://postgrest.org/en/stable/references/errors.html
244
+ const mapping: Record<string, string> = {
245
+ // Authentication/Authorization
246
+ PGRST301: "28000", // invalid_authorization_specification
247
+ PGRST302: "28P01", // invalid_password
248
+ // Permission errors
249
+ "42501": "42501", // insufficient_privilege (pass through)
250
+ PGRST000: "42501", // permission denied (generic)
251
+ // Syntax errors
252
+ "42601": "42601", // syntax_error (pass through)
253
+ // Object errors
254
+ "42P01": "42P01", // undefined_table (pass through)
255
+ PGRST200: "42P01", // table not found
256
+ "42883": "42883", // undefined_function (pass through)
257
+ // Connection errors
258
+ "08000": "08000", // connection_exception (pass through)
259
+ "08003": "08003", // connection_does_not_exist (pass through)
260
+ "08006": "08006", // connection_failure (pass through)
261
+ // Duplicate object
262
+ "42710": "42710", // duplicate_object (pass through)
263
+ };
264
+
265
+ return mapping[code];
266
+ }
267
+
268
+ /**
269
+ * Extract error message from various error formats.
270
+ */
271
+ private extractErrorMessage(errObj: unknown): string {
272
+ if (!errObj || typeof errObj !== "object") {
273
+ return "Unknown Supabase API error";
274
+ }
275
+
276
+ const obj = errObj as Record<string, unknown>;
277
+
278
+ // Try common message fields
279
+ for (const field of ["message", "error", "msg", "description"]) {
280
+ if (typeof obj[field] === "string" && obj[field]) {
281
+ return obj[field] as string;
282
+ }
283
+ }
284
+
285
+ // If error is nested, try to extract from it
286
+ if (obj.error && typeof obj.error === "object") {
287
+ return this.extractErrorMessage(obj.error);
288
+ }
289
+
290
+ return "Unknown Supabase API error";
291
+ }
292
+
293
+ /**
294
+ * Extract a field from error object, trying multiple possible field names.
295
+ */
296
+ private extractField(
297
+ errObj: unknown,
298
+ fieldNames: string[]
299
+ ): string | undefined {
300
+ if (!errObj || typeof errObj !== "object") return undefined;
301
+
302
+ const obj = errObj as Record<string, unknown>;
303
+
304
+ for (const field of fieldNames) {
305
+ if (typeof obj[field] === "string" && obj[field]) {
306
+ return obj[field] as string;
307
+ }
308
+ }
309
+
310
+ return undefined;
311
+ }
312
+
313
+ /**
314
+ * Create a PostgreSQL-compatible error object.
315
+ */
316
+ private createPgError(opts: {
317
+ message: string;
318
+ code?: string;
319
+ detail?: string;
320
+ hint?: string;
321
+ httpStatus?: number;
322
+ supabaseErrorCode?: string;
323
+ }): PgCompatibleError {
324
+ const err = new Error(opts.message) as PgCompatibleError;
325
+
326
+ if (opts.code) err.code = opts.code;
327
+ if (opts.detail) err.detail = opts.detail;
328
+ if (opts.hint) err.hint = opts.hint;
329
+ if (opts.httpStatus) err.httpStatus = opts.httpStatus;
330
+ if (opts.supabaseErrorCode) err.supabaseErrorCode = opts.supabaseErrorCode;
331
+
332
+ return err;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Fetch the database pooler connection string from Supabase Management API.
338
+ * Returns a postgresql:// URL with the specified username but no password.
339
+ *
340
+ * @param config Supabase configuration with projectRef and accessToken
341
+ * @param username Username to include in the URL (e.g., monitoring user)
342
+ * @returns Database URL without password (e.g., "postgresql://user@host:port/postgres")
343
+ */
344
+ export async function fetchPoolerDatabaseUrl(
345
+ config: SupabaseConfig,
346
+ username: string
347
+ ): Promise<string | null> {
348
+ const url = `${SUPABASE_API_BASE}/v1/projects/${encodeURIComponent(config.projectRef)}/config/database/pooler`;
349
+
350
+ try {
351
+ const response = await fetch(url, {
352
+ method: "GET",
353
+ headers: {
354
+ Authorization: `Bearer ${config.accessToken}`,
355
+ },
356
+ });
357
+
358
+ if (!response.ok) {
359
+ return null;
360
+ }
361
+
362
+ const data = await response.json();
363
+
364
+ // The API returns an array of pooler configurations
365
+ // Look for a connection string in the response
366
+ if (Array.isArray(data) && data.length > 0) {
367
+ const pooler = data[0];
368
+ // Build URL from components if available
369
+ if (pooler.db_host && pooler.db_port && pooler.db_name) {
370
+ return `postgresql://${username}@${pooler.db_host}:${pooler.db_port}/${pooler.db_name}`;
371
+ }
372
+ // Fallback: try to extract from connection_string if present
373
+ if (typeof pooler.connection_string === "string") {
374
+ try {
375
+ const connUrl = new URL(pooler.connection_string);
376
+ // Use provided username; handle empty port for default ports (e.g., 5432)
377
+ const portPart = connUrl.port ? `:${connUrl.port}` : "";
378
+ return `postgresql://${username}@${connUrl.hostname}${portPart}${connUrl.pathname}`;
379
+ } catch {
380
+ return null;
381
+ }
382
+ }
383
+ }
384
+
385
+ return null;
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Resolve Supabase configuration from options and environment variables.
393
+ */
394
+ export function resolveSupabaseConfig(opts: {
395
+ accessToken?: string;
396
+ projectRef?: string;
397
+ }): SupabaseConfig {
398
+ const accessToken =
399
+ opts.accessToken?.trim() ||
400
+ process.env.SUPABASE_ACCESS_TOKEN?.trim() ||
401
+ "";
402
+
403
+ const projectRef =
404
+ opts.projectRef?.trim() || process.env.SUPABASE_PROJECT_REF?.trim() || "";
405
+
406
+ if (!accessToken) {
407
+ throw new Error(
408
+ "Supabase access token is required.\n" +
409
+ "Provide it via --supabase-access-token or SUPABASE_ACCESS_TOKEN environment variable.\n" +
410
+ "Generate a token at: https://supabase.com/dashboard/account/tokens"
411
+ );
412
+ }
413
+
414
+ if (!projectRef) {
415
+ throw new Error(
416
+ "Supabase project reference is required.\n" +
417
+ "Provide it via --supabase-project-ref or SUPABASE_PROJECT_REF environment variable.\n" +
418
+ "Find your project ref in the Supabase dashboard URL: https://supabase.com/dashboard/project/<ref>"
419
+ );
420
+ }
421
+
422
+ return { accessToken, projectRef };
423
+ }
424
+
425
+ /**
426
+ * Extract project reference from a Supabase database URL.
427
+ * Supabase database URLs typically look like:
428
+ * - Direct: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres
429
+ * - Pooler (modern): postgresql://postgres.[PROJECT_REF]:[PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres
430
+ * - Pooler (legacy): postgresql://postgres:[PASSWORD]@[PROJECT_REF].pooler.supabase.com:6543/postgres
431
+ *
432
+ * @param dbUrl PostgreSQL connection URL
433
+ * @returns Project reference if found, undefined otherwise
434
+ */
435
+ export function extractProjectRefFromUrl(dbUrl: string): string | undefined {
436
+ try {
437
+ const url = new URL(dbUrl);
438
+ const host = url.hostname;
439
+
440
+ // Match db.<ref>.supabase.co or <ref>.supabase.co patterns (direct connection)
441
+ const match = host.match(/^(?:db\.)?([^.]+)\.supabase\.co$/i);
442
+ if (match && match[1]) {
443
+ return match[1];
444
+ }
445
+
446
+ // Modern pooler URLs: project ref is in the username as postgres.<ref>
447
+ // Example: postgresql://postgres.abcdefghij:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres
448
+ if (host.includes("pooler.supabase.com")) {
449
+ const username = url.username;
450
+ const userMatch = username.match(/^postgres\.([a-z0-9]+)$/i);
451
+ if (userMatch && userMatch[1]) {
452
+ return userMatch[1];
453
+ }
454
+ }
455
+
456
+ // Legacy pooler URLs: <project-ref>.pooler.supabase.com (fallback)
457
+ const poolerMatch = host.match(/^([a-z0-9]+)\.pooler\.supabase\.com$/i);
458
+ if (poolerMatch && poolerMatch[1] && !poolerMatch[1].startsWith("aws-")) {
459
+ return poolerMatch[1];
460
+ }
461
+
462
+ return undefined;
463
+ } catch {
464
+ return undefined;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Apply init plan steps via Supabase Management API.
470
+ * Mirrors the behavior of applyInitPlan() in init.ts but uses Supabase API.
471
+ */
472
+ export async function applyInitPlanViaSupabase(params: {
473
+ client: SupabaseClient;
474
+ plan: {
475
+ monitoringUser: string;
476
+ database: string;
477
+ steps: Array<{
478
+ name: string;
479
+ sql: string;
480
+ params?: unknown[];
481
+ optional?: boolean;
482
+ }>;
483
+ };
484
+ verbose?: boolean;
485
+ }): Promise<{ applied: string[]; skippedOptional: string[] }> {
486
+ const applied: string[] = [];
487
+ const skippedOptional: string[] = [];
488
+
489
+ // Helper to execute a step (each step is wrapped in BEGIN/COMMIT)
490
+ const executeStep = async (step: {
491
+ name: string;
492
+ sql: string;
493
+ optional?: boolean;
494
+ }): Promise<void> => {
495
+ // Wrap in explicit transaction for atomic execution.
496
+ // Note: Supabase API uses pooled connections, so if the transaction fails,
497
+ // PostgreSQL automatically rolls it back - no separate ROLLBACK needed.
498
+ const wrappedSql = `BEGIN;\n${step.sql}\nCOMMIT;`;
499
+ await params.client.query(wrappedSql, false);
500
+ };
501
+
502
+ // Apply non-optional steps first
503
+ for (const step of params.plan.steps.filter((s) => !s.optional)) {
504
+ try {
505
+ if (params.verbose) {
506
+ console.log(`Executing step: ${step.name}`);
507
+ }
508
+ await executeStep(step);
509
+ applied.push(step.name);
510
+ } catch (e) {
511
+ const msg = e instanceof Error ? e.message : String(e);
512
+ const errAny = e as PgCompatibleError;
513
+ const wrapped: PgCompatibleError = new Error(
514
+ `Failed at step "${step.name}": ${msg}`
515
+ ) as PgCompatibleError;
516
+
517
+ // Preserve PostgreSQL error fields for consistent error handling
518
+ const pgErrorFields = [
519
+ "code",
520
+ "detail",
521
+ "hint",
522
+ "position",
523
+ "internalPosition",
524
+ "internalQuery",
525
+ "where",
526
+ "schema",
527
+ "table",
528
+ "column",
529
+ "dataType",
530
+ "constraint",
531
+ "file",
532
+ "line",
533
+ "routine",
534
+ "httpStatus",
535
+ "supabaseErrorCode",
536
+ ] as const;
537
+
538
+ for (const field of pgErrorFields) {
539
+ if (errAny[field] !== undefined) {
540
+ (wrapped as unknown as Record<string, unknown>)[field] = errAny[field];
541
+ }
542
+ }
543
+
544
+ if (e instanceof Error && e.stack) {
545
+ wrapped.stack = e.stack;
546
+ }
547
+
548
+ throw wrapped;
549
+ }
550
+ }
551
+
552
+ // Apply optional steps (failures don't abort)
553
+ for (const step of params.plan.steps.filter((s) => s.optional)) {
554
+ try {
555
+ if (params.verbose) {
556
+ console.log(`Executing optional step: ${step.name}`);
557
+ }
558
+ await executeStep(step);
559
+ applied.push(step.name);
560
+ } catch {
561
+ skippedOptional.push(step.name);
562
+ // best-effort: ignore errors for optional steps
563
+ }
564
+ }
565
+
566
+ return { applied, skippedOptional };
567
+ }
568
+
569
+ /**
570
+ * Verify init setup via Supabase Management API.
571
+ * Mirrors the behavior of verifyInitSetup() in init.ts but uses Supabase API.
572
+ *
573
+ * @param params.client - Supabase client for API calls
574
+ * @param params.database - Database name to verify
575
+ * @param params.monitoringUser - Role name to check permissions for
576
+ * @param params.includeOptionalPermissions - Whether to check optional permissions
577
+ * @returns Object with ok status and arrays of missing required/optional items
578
+ */
579
+ export async function verifyInitSetupViaSupabase(params: {
580
+ client: SupabaseClient;
581
+ database: string;
582
+ monitoringUser: string;
583
+ includeOptionalPermissions: boolean;
584
+ }): Promise<{
585
+ ok: boolean;
586
+ missingRequired: string[];
587
+ missingOptional: string[];
588
+ }> {
589
+ const missingRequired: string[] = [];
590
+ const missingOptional: string[] = [];
591
+
592
+ const role = params.monitoringUser;
593
+ const db = params.database;
594
+
595
+ // Validate role name to prevent SQL injection
596
+ if (!isValidIdentifier(role)) {
597
+ throw new Error(`Invalid monitoring user name: "${role}". Must be a valid PostgreSQL identifier (letters, digits, underscores, max 63 chars, starting with letter or underscore).`);
598
+ }
599
+
600
+ // Check if role exists
601
+ const roleRes = await params.client.query(
602
+ `SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${escapeLiteral(role)}'`,
603
+ true
604
+ );
605
+ const roleExists = roleRes.rowCount > 0;
606
+
607
+ if (!roleExists) {
608
+ missingRequired.push(`role "${role}" does not exist`);
609
+ return { ok: false, missingRequired, missingOptional };
610
+ }
611
+
612
+ // Check CONNECT privilege
613
+ const connectRes = await params.client.query(
614
+ `SELECT has_database_privilege('${escapeLiteral(role)}', '${escapeLiteral(db)}', 'CONNECT') as ok`,
615
+ true
616
+ );
617
+ if (!connectRes.rows?.[0]?.ok) {
618
+ missingRequired.push(`CONNECT on database "${db}"`);
619
+ }
620
+
621
+ // Check pg_monitor membership
622
+ const pgMonitorRes = await params.client.query(
623
+ `SELECT pg_has_role('${escapeLiteral(role)}', 'pg_monitor', 'member') as ok`,
624
+ true
625
+ );
626
+ if (!pgMonitorRes.rows?.[0]?.ok) {
627
+ missingRequired.push("membership in role pg_monitor");
628
+ }
629
+
630
+ // Check SELECT on pg_index
631
+ const pgIndexRes = await params.client.query(
632
+ `SELECT has_table_privilege('${escapeLiteral(role)}', 'pg_catalog.pg_index', 'SELECT') as ok`,
633
+ true
634
+ );
635
+ if (!pgIndexRes.rows?.[0]?.ok) {
636
+ missingRequired.push("SELECT on pg_catalog.pg_index");
637
+ }
638
+
639
+ // Check postgres_ai schema exists and has USAGE privilege
640
+ // First check if schema exists to avoid has_schema_privilege throwing error
641
+ const schemaExistsRes = await params.client.query(
642
+ "SELECT nspname FROM pg_namespace WHERE nspname = 'postgres_ai'",
643
+ true
644
+ );
645
+ if (schemaExistsRes.rowCount === 0) {
646
+ missingRequired.push("schema postgres_ai exists");
647
+ } else {
648
+ const schemaPrivRes = await params.client.query(
649
+ `SELECT has_schema_privilege('${escapeLiteral(role)}', 'postgres_ai', 'USAGE') as ok`,
650
+ true
651
+ );
652
+ if (!schemaPrivRes.rows?.[0]?.ok) {
653
+ missingRequired.push("USAGE on schema postgres_ai");
654
+ }
655
+ }
656
+
657
+ // Check pg_statistic view
658
+ const viewExistsRes = await params.client.query(
659
+ "SELECT to_regclass('postgres_ai.pg_statistic') IS NOT NULL as ok",
660
+ true
661
+ );
662
+ if (!viewExistsRes.rows?.[0]?.ok) {
663
+ missingRequired.push("view postgres_ai.pg_statistic exists");
664
+ } else {
665
+ const viewPrivRes = await params.client.query(
666
+ `SELECT has_table_privilege('${escapeLiteral(role)}', 'postgres_ai.pg_statistic', 'SELECT') as ok`,
667
+ true
668
+ );
669
+ if (!viewPrivRes.rows?.[0]?.ok) {
670
+ missingRequired.push("SELECT on view postgres_ai.pg_statistic");
671
+ }
672
+ }
673
+
674
+ // Check USAGE on public schema (check existence first to avoid has_schema_privilege throwing)
675
+ const publicSchemaExistsRes = await params.client.query(
676
+ "SELECT nspname FROM pg_namespace WHERE nspname = 'public'",
677
+ true
678
+ );
679
+ if (publicSchemaExistsRes.rowCount === 0) {
680
+ missingRequired.push("schema public exists");
681
+ } else {
682
+ const schemaUsageRes = await params.client.query(
683
+ `SELECT has_schema_privilege('${escapeLiteral(role)}', 'public', 'USAGE') as ok`,
684
+ true
685
+ );
686
+ if (!schemaUsageRes.rows?.[0]?.ok) {
687
+ missingRequired.push("USAGE on schema public");
688
+ }
689
+ }
690
+
691
+ // Check search_path
692
+ const rolcfgRes = await params.client.query(
693
+ `SELECT rolconfig FROM pg_catalog.pg_roles WHERE rolname = '${escapeLiteral(role)}'`,
694
+ true
695
+ );
696
+ const rolconfig = rolcfgRes.rows?.[0]?.rolconfig as string[] | null;
697
+ const spLine = Array.isArray(rolconfig)
698
+ ? rolconfig.find((v: string) => String(v).startsWith("search_path="))
699
+ : undefined;
700
+ if (typeof spLine !== "string" || !spLine) {
701
+ missingRequired.push("role search_path is set");
702
+ } else {
703
+ const sp = spLine.toLowerCase();
704
+ if (
705
+ !sp.includes("postgres_ai") ||
706
+ !sp.includes("public") ||
707
+ !sp.includes("pg_catalog")
708
+ ) {
709
+ missingRequired.push(
710
+ "role search_path includes postgres_ai, public and pg_catalog"
711
+ );
712
+ }
713
+ }
714
+
715
+ // Check helper functions - first verify they exist to avoid has_function_privilege errors
716
+ const explainFnExistsRes = await params.client.query(
717
+ "SELECT oid FROM pg_proc WHERE proname = 'explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
718
+ true
719
+ );
720
+ if (explainFnExistsRes.rowCount === 0) {
721
+ missingRequired.push("function postgres_ai.explain_generic exists");
722
+ } else {
723
+ const explainFnRes = await params.client.query(
724
+ `SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`,
725
+ true
726
+ );
727
+ if (!explainFnRes.rows?.[0]?.ok) {
728
+ missingRequired.push(
729
+ "EXECUTE on postgres_ai.explain_generic(text, text, text)"
730
+ );
731
+ }
732
+ }
733
+
734
+ const tableDescribeFnExistsRes = await params.client.query(
735
+ "SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
736
+ true
737
+ );
738
+ if (tableDescribeFnExistsRes.rowCount === 0) {
739
+ missingRequired.push("function postgres_ai.table_describe exists");
740
+ } else {
741
+ const tableDescribeFnRes = await params.client.query(
742
+ `SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.table_describe(text)', 'EXECUTE') as ok`,
743
+ true
744
+ );
745
+ if (!tableDescribeFnRes.rows?.[0]?.ok) {
746
+ missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
747
+ }
748
+ }
749
+
750
+ // Optional permissions
751
+ if (params.includeOptionalPermissions) {
752
+ // RDS tools extension
753
+ const extRes = await params.client.query(
754
+ "SELECT 1 FROM pg_extension WHERE extname = 'rds_tools'",
755
+ true
756
+ );
757
+ if (extRes.rowCount === 0) {
758
+ missingOptional.push("extension rds_tools");
759
+ } else {
760
+ try {
761
+ const fnRes = await params.client.query(
762
+ `SELECT has_function_privilege('${escapeLiteral(role)}', 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok`,
763
+ true
764
+ );
765
+ if (!fnRes.rows?.[0]?.ok) {
766
+ missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
767
+ }
768
+ } catch {
769
+ missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
770
+ }
771
+ }
772
+
773
+ // Self-managed extras (these are hardcoded constants, safe to use directly)
774
+ const optionalFns = [
775
+ "pg_catalog.pg_stat_file(text)",
776
+ "pg_catalog.pg_stat_file(text, boolean)",
777
+ "pg_catalog.pg_ls_dir(text)",
778
+ "pg_catalog.pg_ls_dir(text, boolean, boolean)",
779
+ ];
780
+ for (const fn of optionalFns) {
781
+ try {
782
+ const fnRes = await params.client.query(
783
+ `SELECT has_function_privilege('${escapeLiteral(role)}', '${fn}', 'EXECUTE') as ok`,
784
+ true
785
+ );
786
+ if (!fnRes.rows?.[0]?.ok) {
787
+ missingOptional.push(`EXECUTE on ${fn}`);
788
+ }
789
+ } catch {
790
+ // Function may not exist on this PostgreSQL version
791
+ missingOptional.push(`EXECUTE on ${fn}`);
792
+ }
793
+ }
794
+ }
795
+
796
+ return {
797
+ ok: missingRequired.length === 0,
798
+ missingRequired,
799
+ missingOptional,
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Validate that a string is a valid PostgreSQL identifier.
805
+ * PostgreSQL identifiers can contain letters, digits, and underscores,
806
+ * must start with a letter or underscore, and are max 63 characters.
807
+ */
808
+ function isValidIdentifier(name: string): boolean {
809
+ return /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(name);
810
+ }
811
+
812
+ /**
813
+ * Escape a string literal for use in SQL.
814
+ * Handles null bytes and single quotes for safe SQL interpolation.
815
+ * Note: This is for dynamic query building where parameterized queries aren't possible.
816
+ */
817
+ function escapeLiteral(value: string): string {
818
+ // Reject null bytes which can cause string truncation
819
+ if (value.includes("\0")) {
820
+ throw new Error("SQL literal cannot contain null bytes");
821
+ }
822
+ // Escape single quotes by doubling them
823
+ return value.replace(/'/g, "''");
824
+ }