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/supabase.ts DELETED
@@ -1,826 +0,0 @@
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
- * Note: The username will be automatically suffixed with `.<projectRef>` if not
341
- * already present, as required by Supabase pooler connections.
342
- *
343
- * @param config Supabase configuration with projectRef and accessToken
344
- * @param username Username to include in the URL (e.g., monitoring user).
345
- * Will be transformed to `<username>.<projectRef>` format.
346
- * @returns Database URL without password (e.g., "postgresql://user.project@host:port/postgres"),
347
- * or null if the API call fails or returns no pooler config.
348
- */
349
- export async function fetchPoolerDatabaseUrl(
350
- config: SupabaseConfig,
351
- username: string
352
- ): Promise<string | null> {
353
- // Validate projectRef format to prevent SSRF via crafted project references
354
- if (!isValidProjectRef(config.projectRef)) {
355
- throw new Error(`Invalid Supabase project reference format: "${config.projectRef}". Expected 10-30 alphanumeric characters.`);
356
- }
357
- const url = `${SUPABASE_API_BASE}/v1/projects/${encodeURIComponent(config.projectRef)}/config/database/pooler`;
358
-
359
- // For Supabase pooler connections, the username must include the project ref:
360
- // <user>.<project_ref>
361
- // Example:
362
- // postgresql://postgres_ai_mon.xhaqmsvczjkkvkgdyast@aws-1-eu-west-1.pooler.supabase.com:6543/postgres
363
- const suffix = `.${config.projectRef}`;
364
- const effectiveUsername = username.endsWith(suffix) ? username : `${username}${suffix}`;
365
- // URL-encode the username to handle special characters safely
366
- const encodedUsername = encodeURIComponent(effectiveUsername);
367
- try {
368
- const response = await fetch(url, {
369
- method: "GET",
370
- headers: {
371
- Authorization: `Bearer ${config.accessToken}`,
372
- },
373
- });
374
-
375
- if (!response.ok) {
376
- return null;
377
- }
378
-
379
- const data = await response.json();
380
-
381
- // The API returns an array of pooler configurations
382
- // Look for a connection string in the response
383
- if (Array.isArray(data) && data.length > 0) {
384
- const pooler = data[0];
385
- // Build URL from components if available
386
- if (pooler.db_host && pooler.db_port && pooler.db_name) {
387
- return `postgresql://${encodedUsername}@${pooler.db_host}:${pooler.db_port}/${pooler.db_name}`;
388
- }
389
- // Fallback: try to extract from connection_string if present
390
- if (typeof pooler.connection_string === "string") {
391
- try {
392
- const connUrl = new URL(pooler.connection_string);
393
- // Use provided username; handle empty port for default ports (e.g., 5432)
394
- const portPart = connUrl.port ? `:${connUrl.port}` : "";
395
- return `postgresql://${encodedUsername}@${connUrl.hostname}${portPart}${connUrl.pathname}`;
396
- } catch {
397
- return null;
398
- }
399
- }
400
- }
401
-
402
- return null;
403
- } catch {
404
- return null;
405
- }
406
- }
407
-
408
- /**
409
- * Resolve Supabase configuration from options and environment variables.
410
- */
411
- export function resolveSupabaseConfig(opts: {
412
- accessToken?: string;
413
- projectRef?: string;
414
- }): SupabaseConfig {
415
- const accessToken =
416
- opts.accessToken?.trim() ||
417
- process.env.SUPABASE_ACCESS_TOKEN?.trim() ||
418
- "";
419
-
420
- const projectRef =
421
- opts.projectRef?.trim() || process.env.SUPABASE_PROJECT_REF?.trim() || "";
422
-
423
- if (!accessToken) {
424
- throw new Error(
425
- "Supabase access token is required.\n" +
426
- "Provide it via --supabase-access-token or SUPABASE_ACCESS_TOKEN environment variable.\n" +
427
- "Generate a token at: https://supabase.com/dashboard/account/tokens"
428
- );
429
- }
430
-
431
- if (!projectRef) {
432
- throw new Error(
433
- "Supabase project reference is required.\n" +
434
- "Provide it via --supabase-project-ref or SUPABASE_PROJECT_REF environment variable.\n" +
435
- "Find your project ref in the Supabase dashboard URL: https://supabase.com/dashboard/project/<ref>"
436
- );
437
- }
438
-
439
- return { accessToken, projectRef };
440
- }
441
-
442
- /**
443
- * Extract project reference from a Supabase database URL.
444
- * Supabase database URLs typically look like:
445
- * - Direct: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres
446
- * - Pooler (modern): postgresql://postgres.[PROJECT_REF]:[PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres
447
- * - Pooler (legacy): postgresql://postgres:[PASSWORD]@[PROJECT_REF].pooler.supabase.com:6543/postgres
448
- *
449
- * @param dbUrl PostgreSQL connection URL
450
- * @returns Project reference if found, undefined otherwise
451
- */
452
- export function extractProjectRefFromUrl(dbUrl: string): string | undefined {
453
- try {
454
- const url = new URL(dbUrl);
455
- const host = url.hostname;
456
-
457
- // Match db.<ref>.supabase.co or <ref>.supabase.co patterns (direct connection)
458
- const match = host.match(/^(?:db\.)?([^.]+)\.supabase\.co$/i);
459
- if (match && match[1]) {
460
- return match[1];
461
- }
462
-
463
- // Modern pooler URLs: project ref is in the username as postgres.<ref>
464
- // Example: postgresql://postgres.abcdefghij:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres
465
- if (host.includes("pooler.supabase.com")) {
466
- const username = url.username;
467
- const userMatch = username.match(/^postgres\.([a-z0-9]+)$/i);
468
- if (userMatch && userMatch[1]) {
469
- return userMatch[1];
470
- }
471
- }
472
-
473
- // Legacy pooler URLs: <project-ref>.pooler.supabase.com (fallback)
474
- const poolerMatch = host.match(/^([a-z0-9]+)\.pooler\.supabase\.com$/i);
475
- if (poolerMatch && poolerMatch[1] && !poolerMatch[1].startsWith("aws-")) {
476
- return poolerMatch[1];
477
- }
478
-
479
- return undefined;
480
- } catch {
481
- return undefined;
482
- }
483
- }
484
-
485
- /**
486
- * Apply init plan steps via Supabase Management API.
487
- * Mirrors the behavior of applyInitPlan() in init.ts but uses Supabase API.
488
- */
489
- export async function applyInitPlanViaSupabase(params: {
490
- client: SupabaseClient;
491
- plan: {
492
- monitoringUser: string;
493
- database: string;
494
- steps: Array<{
495
- name: string;
496
- sql: string;
497
- params?: unknown[];
498
- optional?: boolean;
499
- }>;
500
- };
501
- verbose?: boolean;
502
- }): Promise<{ applied: string[]; skippedOptional: string[] }> {
503
- const applied: string[] = [];
504
- const skippedOptional: string[] = [];
505
-
506
- // Helper to execute a step (each step is wrapped in BEGIN/COMMIT)
507
- const executeStep = async (step: {
508
- name: string;
509
- sql: string;
510
- optional?: boolean;
511
- }): Promise<void> => {
512
- // Wrap in explicit transaction for atomic execution.
513
- // Note: Supabase API uses pooled connections, so if the transaction fails,
514
- // PostgreSQL automatically rolls it back - no separate ROLLBACK needed.
515
- const wrappedSql = `BEGIN;\n${step.sql}\nCOMMIT;`;
516
- await params.client.query(wrappedSql, false);
517
- };
518
-
519
- // Apply non-optional steps first
520
- for (const step of params.plan.steps.filter((s) => !s.optional)) {
521
- try {
522
- if (params.verbose) {
523
- console.log(`Executing step: ${step.name}`);
524
- }
525
- await executeStep(step);
526
- applied.push(step.name);
527
- } catch (e) {
528
- const msg = e instanceof Error ? e.message : String(e);
529
- const errAny = e as PgCompatibleError;
530
- const wrapped: PgCompatibleError = new Error(
531
- `Failed at step "${step.name}": ${msg}`
532
- ) as PgCompatibleError;
533
-
534
- // Preserve PostgreSQL error fields for consistent error handling
535
- const pgErrorFields = [
536
- "code",
537
- "detail",
538
- "hint",
539
- "position",
540
- "internalPosition",
541
- "internalQuery",
542
- "where",
543
- "schema",
544
- "table",
545
- "column",
546
- "dataType",
547
- "constraint",
548
- "file",
549
- "line",
550
- "routine",
551
- "httpStatus",
552
- "supabaseErrorCode",
553
- ] as const;
554
-
555
- for (const field of pgErrorFields) {
556
- if (errAny[field] !== undefined) {
557
- (wrapped as unknown as Record<string, unknown>)[field] = errAny[field];
558
- }
559
- }
560
-
561
- if (e instanceof Error && e.stack) {
562
- wrapped.stack = e.stack;
563
- }
564
-
565
- throw wrapped;
566
- }
567
- }
568
-
569
- // Apply optional steps (failures don't abort)
570
- for (const step of params.plan.steps.filter((s) => s.optional)) {
571
- try {
572
- if (params.verbose) {
573
- console.log(`Executing optional step: ${step.name}`);
574
- }
575
- await executeStep(step);
576
- applied.push(step.name);
577
- } catch {
578
- skippedOptional.push(step.name);
579
- // best-effort: ignore errors for optional steps
580
- }
581
- }
582
-
583
- return { applied, skippedOptional };
584
- }
585
-
586
- /**
587
- * Verify init setup via Supabase Management API.
588
- * Mirrors the behavior of verifyInitSetup() in init.ts but uses Supabase API.
589
- *
590
- * @param params.client - Supabase client for API calls
591
- * @param params.database - Database name to verify
592
- * @param params.monitoringUser - Role name to check permissions for
593
- * @param params.includeOptionalPermissions - Whether to check optional permissions
594
- * @returns Object with ok status and arrays of missing required/optional items
595
- */
596
- export async function verifyInitSetupViaSupabase(params: {
597
- client: SupabaseClient;
598
- database: string;
599
- monitoringUser: string;
600
- includeOptionalPermissions: boolean;
601
- }): Promise<{
602
- ok: boolean;
603
- missingRequired: string[];
604
- missingOptional: string[];
605
- }> {
606
- const missingRequired: string[] = [];
607
- const missingOptional: string[] = [];
608
-
609
- const role = params.monitoringUser;
610
- const db = params.database;
611
-
612
- // Validate role name to prevent SQL injection
613
- if (!isValidIdentifier(role)) {
614
- 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).`);
615
- }
616
-
617
- // Check if role exists
618
- const roleRes = await params.client.query(
619
- `SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${escapeLiteral(role)}'`,
620
- true
621
- );
622
- const roleExists = roleRes.rowCount > 0;
623
-
624
- if (!roleExists) {
625
- missingRequired.push(`role "${role}" does not exist`);
626
- return { ok: false, missingRequired, missingOptional };
627
- }
628
-
629
- // Check CONNECT privilege
630
- const connectRes = await params.client.query(
631
- `SELECT has_database_privilege('${escapeLiteral(role)}', '${escapeLiteral(db)}', 'CONNECT') as ok`,
632
- true
633
- );
634
- if (!connectRes.rows?.[0]?.ok) {
635
- missingRequired.push(`CONNECT on database "${db}"`);
636
- }
637
-
638
- // Check pg_monitor membership
639
- const pgMonitorRes = await params.client.query(
640
- `SELECT pg_has_role('${escapeLiteral(role)}', 'pg_monitor', 'member') as ok`,
641
- true
642
- );
643
- if (!pgMonitorRes.rows?.[0]?.ok) {
644
- missingRequired.push("membership in role pg_monitor");
645
- }
646
-
647
- // Check SELECT on pg_index
648
- const pgIndexRes = await params.client.query(
649
- `SELECT has_table_privilege('${escapeLiteral(role)}', 'pg_catalog.pg_index', 'SELECT') as ok`,
650
- true
651
- );
652
- if (!pgIndexRes.rows?.[0]?.ok) {
653
- missingRequired.push("SELECT on pg_catalog.pg_index");
654
- }
655
-
656
- // Check postgres_ai schema exists and has USAGE privilege
657
- // First check if schema exists to avoid has_schema_privilege throwing error
658
- const schemaExistsRes = await params.client.query(
659
- "SELECT nspname FROM pg_namespace WHERE nspname = 'postgres_ai'",
660
- true
661
- );
662
- if (schemaExistsRes.rowCount === 0) {
663
- missingRequired.push("schema postgres_ai exists");
664
- } else {
665
- const schemaPrivRes = await params.client.query(
666
- `SELECT has_schema_privilege('${escapeLiteral(role)}', 'postgres_ai', 'USAGE') as ok`,
667
- true
668
- );
669
- if (!schemaPrivRes.rows?.[0]?.ok) {
670
- missingRequired.push("USAGE on schema postgres_ai");
671
- }
672
- }
673
-
674
- // Check pg_statistic view
675
- const viewExistsRes = await params.client.query(
676
- `SELECT CASE
677
- WHEN NOT has_schema_privilege(current_user, 'postgres_ai', 'USAGE') THEN NULL
678
- ELSE to_regclass('postgres_ai.pg_statistic') IS NOT NULL
679
- END as ok`,
680
- true
681
- );
682
- if (!viewExistsRes.rows?.[0]?.ok) {
683
- missingRequired.push("view postgres_ai.pg_statistic exists");
684
- } else {
685
- const viewPrivRes = await params.client.query(
686
- `SELECT has_table_privilege('${escapeLiteral(role)}', 'postgres_ai.pg_statistic', 'SELECT') as ok`,
687
- true
688
- );
689
- if (!viewPrivRes.rows?.[0]?.ok) {
690
- missingRequired.push("SELECT on view postgres_ai.pg_statistic");
691
- }
692
- }
693
-
694
- // Check USAGE on public schema (check existence first to avoid has_schema_privilege throwing)
695
- const publicSchemaExistsRes = await params.client.query(
696
- "SELECT nspname FROM pg_namespace WHERE nspname = 'public'",
697
- true
698
- );
699
- if (publicSchemaExistsRes.rowCount === 0) {
700
- missingRequired.push("schema public exists");
701
- } else {
702
- const schemaUsageRes = await params.client.query(
703
- `SELECT has_schema_privilege('${escapeLiteral(role)}', 'public', 'USAGE') as ok`,
704
- true
705
- );
706
- if (!schemaUsageRes.rows?.[0]?.ok) {
707
- missingRequired.push("USAGE on schema public");
708
- }
709
- }
710
-
711
- // Check search_path
712
- const rolcfgRes = await params.client.query(
713
- `SELECT rolconfig FROM pg_catalog.pg_roles WHERE rolname = '${escapeLiteral(role)}'`,
714
- true
715
- );
716
- const rolconfig = rolcfgRes.rows?.[0]?.rolconfig as string[] | null;
717
- const spLine = Array.isArray(rolconfig)
718
- ? rolconfig.find((v: string) => String(v).startsWith("search_path="))
719
- : undefined;
720
- if (typeof spLine !== "string" || !spLine) {
721
- missingRequired.push("role search_path is set");
722
- } else {
723
- const sp = spLine.toLowerCase();
724
- if (
725
- !sp.includes("postgres_ai") ||
726
- !sp.includes("public") ||
727
- !sp.includes("pg_catalog")
728
- ) {
729
- missingRequired.push(
730
- "role search_path includes postgres_ai, public and pg_catalog"
731
- );
732
- }
733
- }
734
-
735
- // Check helper functions - first verify they exist to avoid has_function_privilege errors
736
- const tableDescribeFnExistsRes = await params.client.query(
737
- "SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
738
- true
739
- );
740
- if (tableDescribeFnExistsRes.rowCount === 0) {
741
- missingRequired.push("function postgres_ai.table_describe exists");
742
- } else {
743
- const tableDescribeFnRes = await params.client.query(
744
- `SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.table_describe(text)', 'EXECUTE') as ok`,
745
- true
746
- );
747
- if (!tableDescribeFnRes.rows?.[0]?.ok) {
748
- missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
749
- }
750
- }
751
-
752
- // Optional permissions
753
- if (params.includeOptionalPermissions) {
754
- // RDS tools extension
755
- const extRes = await params.client.query(
756
- "SELECT 1 FROM pg_extension WHERE extname = 'rds_tools'",
757
- true
758
- );
759
- if (extRes.rowCount === 0) {
760
- missingOptional.push("extension rds_tools");
761
- } else {
762
- try {
763
- const fnRes = await params.client.query(
764
- `SELECT has_function_privilege('${escapeLiteral(role)}', 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok`,
765
- true
766
- );
767
- if (!fnRes.rows?.[0]?.ok) {
768
- missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
769
- }
770
- } catch {
771
- missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
772
- }
773
- }
774
-
775
- // Self-managed extras (these are hardcoded constants, safe to use directly)
776
- const optionalFns = [
777
- "pg_catalog.pg_stat_file(text)",
778
- "pg_catalog.pg_stat_file(text, boolean)",
779
- "pg_catalog.pg_ls_dir(text)",
780
- "pg_catalog.pg_ls_dir(text, boolean, boolean)",
781
- ];
782
- for (const fn of optionalFns) {
783
- try {
784
- const fnRes = await params.client.query(
785
- `SELECT has_function_privilege('${escapeLiteral(role)}', '${fn}', 'EXECUTE') as ok`,
786
- true
787
- );
788
- if (!fnRes.rows?.[0]?.ok) {
789
- missingOptional.push(`EXECUTE on ${fn}`);
790
- }
791
- } catch {
792
- // Function may not exist on this PostgreSQL version
793
- missingOptional.push(`EXECUTE on ${fn}`);
794
- }
795
- }
796
- }
797
-
798
- return {
799
- ok: missingRequired.length === 0,
800
- missingRequired,
801
- missingOptional,
802
- };
803
- }
804
-
805
- /**
806
- * Validate that a string is a valid PostgreSQL identifier.
807
- * PostgreSQL identifiers can contain letters, digits, and underscores,
808
- * must start with a letter or underscore, and are max 63 characters.
809
- */
810
- function isValidIdentifier(name: string): boolean {
811
- return /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(name);
812
- }
813
-
814
- /**
815
- * Escape a string literal for use in SQL.
816
- * Handles null bytes and single quotes for safe SQL interpolation.
817
- * Note: This is for dynamic query building where parameterized queries aren't possible.
818
- */
819
- function escapeLiteral(value: string): string {
820
- // Reject null bytes which can cause string truncation
821
- if (value.includes("\0")) {
822
- throw new Error("SQL literal cannot contain null bytes");
823
- }
824
- // Escape single quotes by doubling them
825
- return value.replace(/'/g, "''");
826
- }