postgresai 0.14.0-dev.7 → 0.14.0-dev.71
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.
- package/README.md +161 -61
- package/bin/postgres-ai.ts +1982 -404
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +29395 -1576
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.permissions.sql +37 -0
- package/dist/sql/03.optional_rds.sql +6 -0
- package/dist/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/05.helpers.sql +439 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.permissions.sql +37 -0
- package/dist/sql/sql/03.optional_rds.sql +6 -0
- package/dist/sql/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/sql/05.helpers.sql +439 -0
- package/lib/auth-server.ts +124 -106
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup.ts +1396 -0
- package/lib/config.ts +6 -3
- package/lib/init.ts +568 -155
- package/lib/issues.ts +400 -191
- package/lib/mcp-server.ts +213 -90
- package/lib/metrics-embedded.ts +79 -0
- package/lib/metrics-loader.ts +127 -0
- package/lib/supabase.ts +769 -0
- package/lib/util.ts +61 -0
- package/package.json +20 -10
- package/packages/postgres-ai/README.md +26 -0
- package/packages/postgres-ai/bin/postgres-ai.js +27 -0
- package/packages/postgres-ai/package.json +27 -0
- package/scripts/embed-metrics.ts +154 -0
- package/sql/01.role.sql +16 -0
- package/sql/02.permissions.sql +37 -0
- package/sql/03.optional_rds.sql +6 -0
- package/sql/04.optional_self_managed.sql +8 -0
- package/sql/05.helpers.sql +439 -0
- package/test/auth.test.ts +258 -0
- package/test/checkup.integration.test.ts +321 -0
- package/test/checkup.test.ts +1117 -0
- package/test/config-consistency.test.ts +36 -0
- package/test/init.integration.test.ts +500 -0
- package/test/init.test.ts +682 -0
- package/test/issues.cli.test.ts +314 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +988 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +128 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/init.d.ts +0 -61
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -359
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -269
- package/test/init.test.cjs +0 -69
package/lib/supabase.ts
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
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
|
+
* Resolve Supabase configuration from options and environment variables.
|
|
338
|
+
*/
|
|
339
|
+
export function resolveSupabaseConfig(opts: {
|
|
340
|
+
accessToken?: string;
|
|
341
|
+
projectRef?: string;
|
|
342
|
+
}): SupabaseConfig {
|
|
343
|
+
const accessToken =
|
|
344
|
+
opts.accessToken?.trim() ||
|
|
345
|
+
process.env.SUPABASE_ACCESS_TOKEN?.trim() ||
|
|
346
|
+
"";
|
|
347
|
+
|
|
348
|
+
const projectRef =
|
|
349
|
+
opts.projectRef?.trim() || process.env.SUPABASE_PROJECT_REF?.trim() || "";
|
|
350
|
+
|
|
351
|
+
if (!accessToken) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
"Supabase access token is required.\n" +
|
|
354
|
+
"Provide it via --supabase-access-token or SUPABASE_ACCESS_TOKEN environment variable.\n" +
|
|
355
|
+
"Generate a token at: https://supabase.com/dashboard/account/tokens"
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!projectRef) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
"Supabase project reference is required.\n" +
|
|
362
|
+
"Provide it via --supabase-project-ref or SUPABASE_PROJECT_REF environment variable.\n" +
|
|
363
|
+
"Find your project ref in the Supabase dashboard URL: https://supabase.com/dashboard/project/<ref>"
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { accessToken, projectRef };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Extract project reference from a Supabase database URL.
|
|
372
|
+
* Supabase database URLs typically look like:
|
|
373
|
+
* - Direct: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres
|
|
374
|
+
* - Pooler (modern): postgresql://postgres.[PROJECT_REF]:[PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres
|
|
375
|
+
* - Pooler (legacy): postgresql://postgres:[PASSWORD]@[PROJECT_REF].pooler.supabase.com:6543/postgres
|
|
376
|
+
*
|
|
377
|
+
* @param dbUrl PostgreSQL connection URL
|
|
378
|
+
* @returns Project reference if found, undefined otherwise
|
|
379
|
+
*/
|
|
380
|
+
export function extractProjectRefFromUrl(dbUrl: string): string | undefined {
|
|
381
|
+
try {
|
|
382
|
+
const url = new URL(dbUrl);
|
|
383
|
+
const host = url.hostname;
|
|
384
|
+
|
|
385
|
+
// Match db.<ref>.supabase.co or <ref>.supabase.co patterns (direct connection)
|
|
386
|
+
const match = host.match(/^(?:db\.)?([^.]+)\.supabase\.co$/i);
|
|
387
|
+
if (match && match[1]) {
|
|
388
|
+
return match[1];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Modern pooler URLs: project ref is in the username as postgres.<ref>
|
|
392
|
+
// Example: postgresql://postgres.abcdefghij:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres
|
|
393
|
+
if (host.includes("pooler.supabase.com")) {
|
|
394
|
+
const username = url.username;
|
|
395
|
+
const userMatch = username.match(/^postgres\.([a-z0-9]+)$/i);
|
|
396
|
+
if (userMatch && userMatch[1]) {
|
|
397
|
+
return userMatch[1];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Legacy pooler URLs: <project-ref>.pooler.supabase.com (fallback)
|
|
402
|
+
const poolerMatch = host.match(/^([a-z0-9]+)\.pooler\.supabase\.com$/i);
|
|
403
|
+
if (poolerMatch && poolerMatch[1] && !poolerMatch[1].startsWith("aws-")) {
|
|
404
|
+
return poolerMatch[1];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return undefined;
|
|
408
|
+
} catch {
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Apply init plan steps via Supabase Management API.
|
|
415
|
+
* Mirrors the behavior of applyInitPlan() in init.ts but uses Supabase API.
|
|
416
|
+
*/
|
|
417
|
+
export async function applyInitPlanViaSupabase(params: {
|
|
418
|
+
client: SupabaseClient;
|
|
419
|
+
plan: {
|
|
420
|
+
monitoringUser: string;
|
|
421
|
+
database: string;
|
|
422
|
+
steps: Array<{
|
|
423
|
+
name: string;
|
|
424
|
+
sql: string;
|
|
425
|
+
params?: unknown[];
|
|
426
|
+
optional?: boolean;
|
|
427
|
+
}>;
|
|
428
|
+
};
|
|
429
|
+
verbose?: boolean;
|
|
430
|
+
}): Promise<{ applied: string[]; skippedOptional: string[] }> {
|
|
431
|
+
const applied: string[] = [];
|
|
432
|
+
const skippedOptional: string[] = [];
|
|
433
|
+
|
|
434
|
+
// Helper to execute a step (each step is wrapped in BEGIN/COMMIT)
|
|
435
|
+
const executeStep = async (step: {
|
|
436
|
+
name: string;
|
|
437
|
+
sql: string;
|
|
438
|
+
optional?: boolean;
|
|
439
|
+
}): Promise<void> => {
|
|
440
|
+
// Wrap in explicit transaction for atomic execution.
|
|
441
|
+
// Note: Supabase API uses pooled connections, so if the transaction fails,
|
|
442
|
+
// PostgreSQL automatically rolls it back - no separate ROLLBACK needed.
|
|
443
|
+
const wrappedSql = `BEGIN;\n${step.sql}\nCOMMIT;`;
|
|
444
|
+
await params.client.query(wrappedSql, false);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// Apply non-optional steps first
|
|
448
|
+
for (const step of params.plan.steps.filter((s) => !s.optional)) {
|
|
449
|
+
try {
|
|
450
|
+
if (params.verbose) {
|
|
451
|
+
console.log(`Executing step: ${step.name}`);
|
|
452
|
+
}
|
|
453
|
+
await executeStep(step);
|
|
454
|
+
applied.push(step.name);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
457
|
+
const errAny = e as PgCompatibleError;
|
|
458
|
+
const wrapped: PgCompatibleError = new Error(
|
|
459
|
+
`Failed at step "${step.name}": ${msg}`
|
|
460
|
+
) as PgCompatibleError;
|
|
461
|
+
|
|
462
|
+
// Preserve PostgreSQL error fields for consistent error handling
|
|
463
|
+
const pgErrorFields = [
|
|
464
|
+
"code",
|
|
465
|
+
"detail",
|
|
466
|
+
"hint",
|
|
467
|
+
"position",
|
|
468
|
+
"internalPosition",
|
|
469
|
+
"internalQuery",
|
|
470
|
+
"where",
|
|
471
|
+
"schema",
|
|
472
|
+
"table",
|
|
473
|
+
"column",
|
|
474
|
+
"dataType",
|
|
475
|
+
"constraint",
|
|
476
|
+
"file",
|
|
477
|
+
"line",
|
|
478
|
+
"routine",
|
|
479
|
+
"httpStatus",
|
|
480
|
+
"supabaseErrorCode",
|
|
481
|
+
] as const;
|
|
482
|
+
|
|
483
|
+
for (const field of pgErrorFields) {
|
|
484
|
+
if (errAny[field] !== undefined) {
|
|
485
|
+
(wrapped as unknown as Record<string, unknown>)[field] = errAny[field];
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (e instanceof Error && e.stack) {
|
|
490
|
+
wrapped.stack = e.stack;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
throw wrapped;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Apply optional steps (failures don't abort)
|
|
498
|
+
for (const step of params.plan.steps.filter((s) => s.optional)) {
|
|
499
|
+
try {
|
|
500
|
+
if (params.verbose) {
|
|
501
|
+
console.log(`Executing optional step: ${step.name}`);
|
|
502
|
+
}
|
|
503
|
+
await executeStep(step);
|
|
504
|
+
applied.push(step.name);
|
|
505
|
+
} catch {
|
|
506
|
+
skippedOptional.push(step.name);
|
|
507
|
+
// best-effort: ignore errors for optional steps
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { applied, skippedOptional };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Verify init setup via Supabase Management API.
|
|
516
|
+
* Mirrors the behavior of verifyInitSetup() in init.ts but uses Supabase API.
|
|
517
|
+
*
|
|
518
|
+
* @param params.client - Supabase client for API calls
|
|
519
|
+
* @param params.database - Database name to verify
|
|
520
|
+
* @param params.monitoringUser - Role name to check permissions for
|
|
521
|
+
* @param params.includeOptionalPermissions - Whether to check optional permissions
|
|
522
|
+
* @returns Object with ok status and arrays of missing required/optional items
|
|
523
|
+
*/
|
|
524
|
+
export async function verifyInitSetupViaSupabase(params: {
|
|
525
|
+
client: SupabaseClient;
|
|
526
|
+
database: string;
|
|
527
|
+
monitoringUser: string;
|
|
528
|
+
includeOptionalPermissions: boolean;
|
|
529
|
+
}): Promise<{
|
|
530
|
+
ok: boolean;
|
|
531
|
+
missingRequired: string[];
|
|
532
|
+
missingOptional: string[];
|
|
533
|
+
}> {
|
|
534
|
+
const missingRequired: string[] = [];
|
|
535
|
+
const missingOptional: string[] = [];
|
|
536
|
+
|
|
537
|
+
const role = params.monitoringUser;
|
|
538
|
+
const db = params.database;
|
|
539
|
+
|
|
540
|
+
// Validate role name to prevent SQL injection
|
|
541
|
+
if (!isValidIdentifier(role)) {
|
|
542
|
+
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).`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Check if role exists
|
|
546
|
+
const roleRes = await params.client.query(
|
|
547
|
+
`SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${escapeLiteral(role)}'`,
|
|
548
|
+
true
|
|
549
|
+
);
|
|
550
|
+
const roleExists = roleRes.rowCount > 0;
|
|
551
|
+
|
|
552
|
+
if (!roleExists) {
|
|
553
|
+
missingRequired.push(`role "${role}" does not exist`);
|
|
554
|
+
return { ok: false, missingRequired, missingOptional };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Check CONNECT privilege
|
|
558
|
+
const connectRes = await params.client.query(
|
|
559
|
+
`SELECT has_database_privilege('${escapeLiteral(role)}', '${escapeLiteral(db)}', 'CONNECT') as ok`,
|
|
560
|
+
true
|
|
561
|
+
);
|
|
562
|
+
if (!connectRes.rows?.[0]?.ok) {
|
|
563
|
+
missingRequired.push(`CONNECT on database "${db}"`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Check pg_monitor membership
|
|
567
|
+
const pgMonitorRes = await params.client.query(
|
|
568
|
+
`SELECT pg_has_role('${escapeLiteral(role)}', 'pg_monitor', 'member') as ok`,
|
|
569
|
+
true
|
|
570
|
+
);
|
|
571
|
+
if (!pgMonitorRes.rows?.[0]?.ok) {
|
|
572
|
+
missingRequired.push("membership in role pg_monitor");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Check SELECT on pg_index
|
|
576
|
+
const pgIndexRes = await params.client.query(
|
|
577
|
+
`SELECT has_table_privilege('${escapeLiteral(role)}', 'pg_catalog.pg_index', 'SELECT') as ok`,
|
|
578
|
+
true
|
|
579
|
+
);
|
|
580
|
+
if (!pgIndexRes.rows?.[0]?.ok) {
|
|
581
|
+
missingRequired.push("SELECT on pg_catalog.pg_index");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Check postgres_ai schema exists and has USAGE privilege
|
|
585
|
+
// First check if schema exists to avoid has_schema_privilege throwing error
|
|
586
|
+
const schemaExistsRes = await params.client.query(
|
|
587
|
+
"SELECT nspname FROM pg_namespace WHERE nspname = 'postgres_ai'",
|
|
588
|
+
true
|
|
589
|
+
);
|
|
590
|
+
if (schemaExistsRes.rowCount === 0) {
|
|
591
|
+
missingRequired.push("schema postgres_ai exists");
|
|
592
|
+
} else {
|
|
593
|
+
const schemaPrivRes = await params.client.query(
|
|
594
|
+
`SELECT has_schema_privilege('${escapeLiteral(role)}', 'postgres_ai', 'USAGE') as ok`,
|
|
595
|
+
true
|
|
596
|
+
);
|
|
597
|
+
if (!schemaPrivRes.rows?.[0]?.ok) {
|
|
598
|
+
missingRequired.push("USAGE on schema postgres_ai");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Check pg_statistic view
|
|
603
|
+
const viewExistsRes = await params.client.query(
|
|
604
|
+
"SELECT to_regclass('postgres_ai.pg_statistic') IS NOT NULL as ok",
|
|
605
|
+
true
|
|
606
|
+
);
|
|
607
|
+
if (!viewExistsRes.rows?.[0]?.ok) {
|
|
608
|
+
missingRequired.push("view postgres_ai.pg_statistic exists");
|
|
609
|
+
} else {
|
|
610
|
+
const viewPrivRes = await params.client.query(
|
|
611
|
+
`SELECT has_table_privilege('${escapeLiteral(role)}', 'postgres_ai.pg_statistic', 'SELECT') as ok`,
|
|
612
|
+
true
|
|
613
|
+
);
|
|
614
|
+
if (!viewPrivRes.rows?.[0]?.ok) {
|
|
615
|
+
missingRequired.push("SELECT on view postgres_ai.pg_statistic");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Check USAGE on public schema (check existence first to avoid has_schema_privilege throwing)
|
|
620
|
+
const publicSchemaExistsRes = await params.client.query(
|
|
621
|
+
"SELECT nspname FROM pg_namespace WHERE nspname = 'public'",
|
|
622
|
+
true
|
|
623
|
+
);
|
|
624
|
+
if (publicSchemaExistsRes.rowCount === 0) {
|
|
625
|
+
missingRequired.push("schema public exists");
|
|
626
|
+
} else {
|
|
627
|
+
const schemaUsageRes = await params.client.query(
|
|
628
|
+
`SELECT has_schema_privilege('${escapeLiteral(role)}', 'public', 'USAGE') as ok`,
|
|
629
|
+
true
|
|
630
|
+
);
|
|
631
|
+
if (!schemaUsageRes.rows?.[0]?.ok) {
|
|
632
|
+
missingRequired.push("USAGE on schema public");
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Check search_path
|
|
637
|
+
const rolcfgRes = await params.client.query(
|
|
638
|
+
`SELECT rolconfig FROM pg_catalog.pg_roles WHERE rolname = '${escapeLiteral(role)}'`,
|
|
639
|
+
true
|
|
640
|
+
);
|
|
641
|
+
const rolconfig = rolcfgRes.rows?.[0]?.rolconfig as string[] | null;
|
|
642
|
+
const spLine = Array.isArray(rolconfig)
|
|
643
|
+
? rolconfig.find((v: string) => String(v).startsWith("search_path="))
|
|
644
|
+
: undefined;
|
|
645
|
+
if (typeof spLine !== "string" || !spLine) {
|
|
646
|
+
missingRequired.push("role search_path is set");
|
|
647
|
+
} else {
|
|
648
|
+
const sp = spLine.toLowerCase();
|
|
649
|
+
if (
|
|
650
|
+
!sp.includes("postgres_ai") ||
|
|
651
|
+
!sp.includes("public") ||
|
|
652
|
+
!sp.includes("pg_catalog")
|
|
653
|
+
) {
|
|
654
|
+
missingRequired.push(
|
|
655
|
+
"role search_path includes postgres_ai, public and pg_catalog"
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Check helper functions - first verify they exist to avoid has_function_privilege errors
|
|
661
|
+
const explainFnExistsRes = await params.client.query(
|
|
662
|
+
"SELECT oid FROM pg_proc WHERE proname = 'explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
|
|
663
|
+
true
|
|
664
|
+
);
|
|
665
|
+
if (explainFnExistsRes.rowCount === 0) {
|
|
666
|
+
missingRequired.push("function postgres_ai.explain_generic exists");
|
|
667
|
+
} else {
|
|
668
|
+
const explainFnRes = await params.client.query(
|
|
669
|
+
`SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`,
|
|
670
|
+
true
|
|
671
|
+
);
|
|
672
|
+
if (!explainFnRes.rows?.[0]?.ok) {
|
|
673
|
+
missingRequired.push(
|
|
674
|
+
"EXECUTE on postgres_ai.explain_generic(text, text, text)"
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const tableDescribeFnExistsRes = await params.client.query(
|
|
680
|
+
"SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
|
|
681
|
+
true
|
|
682
|
+
);
|
|
683
|
+
if (tableDescribeFnExistsRes.rowCount === 0) {
|
|
684
|
+
missingRequired.push("function postgres_ai.table_describe exists");
|
|
685
|
+
} else {
|
|
686
|
+
const tableDescribeFnRes = await params.client.query(
|
|
687
|
+
`SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.table_describe(text)', 'EXECUTE') as ok`,
|
|
688
|
+
true
|
|
689
|
+
);
|
|
690
|
+
if (!tableDescribeFnRes.rows?.[0]?.ok) {
|
|
691
|
+
missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Optional permissions
|
|
696
|
+
if (params.includeOptionalPermissions) {
|
|
697
|
+
// RDS tools extension
|
|
698
|
+
const extRes = await params.client.query(
|
|
699
|
+
"SELECT 1 FROM pg_extension WHERE extname = 'rds_tools'",
|
|
700
|
+
true
|
|
701
|
+
);
|
|
702
|
+
if (extRes.rowCount === 0) {
|
|
703
|
+
missingOptional.push("extension rds_tools");
|
|
704
|
+
} else {
|
|
705
|
+
try {
|
|
706
|
+
const fnRes = await params.client.query(
|
|
707
|
+
`SELECT has_function_privilege('${escapeLiteral(role)}', 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok`,
|
|
708
|
+
true
|
|
709
|
+
);
|
|
710
|
+
if (!fnRes.rows?.[0]?.ok) {
|
|
711
|
+
missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Self-managed extras (these are hardcoded constants, safe to use directly)
|
|
719
|
+
const optionalFns = [
|
|
720
|
+
"pg_catalog.pg_stat_file(text)",
|
|
721
|
+
"pg_catalog.pg_stat_file(text, boolean)",
|
|
722
|
+
"pg_catalog.pg_ls_dir(text)",
|
|
723
|
+
"pg_catalog.pg_ls_dir(text, boolean, boolean)",
|
|
724
|
+
];
|
|
725
|
+
for (const fn of optionalFns) {
|
|
726
|
+
try {
|
|
727
|
+
const fnRes = await params.client.query(
|
|
728
|
+
`SELECT has_function_privilege('${escapeLiteral(role)}', '${fn}', 'EXECUTE') as ok`,
|
|
729
|
+
true
|
|
730
|
+
);
|
|
731
|
+
if (!fnRes.rows?.[0]?.ok) {
|
|
732
|
+
missingOptional.push(`EXECUTE on ${fn}`);
|
|
733
|
+
}
|
|
734
|
+
} catch {
|
|
735
|
+
// Function may not exist on this PostgreSQL version
|
|
736
|
+
missingOptional.push(`EXECUTE on ${fn}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
ok: missingRequired.length === 0,
|
|
743
|
+
missingRequired,
|
|
744
|
+
missingOptional,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Validate that a string is a valid PostgreSQL identifier.
|
|
750
|
+
* PostgreSQL identifiers can contain letters, digits, and underscores,
|
|
751
|
+
* must start with a letter or underscore, and are max 63 characters.
|
|
752
|
+
*/
|
|
753
|
+
function isValidIdentifier(name: string): boolean {
|
|
754
|
+
return /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(name);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Escape a string literal for use in SQL.
|
|
759
|
+
* Handles null bytes and single quotes for safe SQL interpolation.
|
|
760
|
+
* Note: This is for dynamic query building where parameterized queries aren't possible.
|
|
761
|
+
*/
|
|
762
|
+
function escapeLiteral(value: string): string {
|
|
763
|
+
// Reject null bytes which can cause string truncation
|
|
764
|
+
if (value.includes("\0")) {
|
|
765
|
+
throw new Error("SQL literal cannot contain null bytes");
|
|
766
|
+
}
|
|
767
|
+
// Escape single quotes by doubling them
|
|
768
|
+
return value.replace(/'/g, "''");
|
|
769
|
+
}
|