postgresai 0.16.0-dev.1 → 0.16.0-dev.11
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/CHANGELOG.md +11 -0
- package/bin/postgres-ai.ts +408 -25
- package/dist/bin/postgres-ai.js +940 -90
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -8
- package/lib/joe.ts +703 -0
- package/lib/supabase.ts +0 -18
- package/lib/util.ts +241 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/auth.test.ts +30 -1
- package/test/checkup.integration.test.ts +31 -21
- package/test/checkup.test.ts +48 -3
- package/test/init.integration.test.ts +9 -79
- package/test/init.test.ts +35 -0
- package/test/joe.cli.test.ts +628 -0
- package/test/joe.test.ts +855 -0
- package/test/monitoring.test.ts +54 -3
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
- package/test/util.test.ts +227 -1
package/lib/supabase.ts
CHANGED
|
@@ -733,24 +733,6 @@ export async function verifyInitSetupViaSupabase(params: {
|
|
|
733
733
|
}
|
|
734
734
|
|
|
735
735
|
// Check helper functions - first verify they exist to avoid has_function_privilege errors
|
|
736
|
-
const explainFnExistsRes = await params.client.query(
|
|
737
|
-
"SELECT oid FROM pg_proc WHERE proname = 'explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
|
|
738
|
-
true
|
|
739
|
-
);
|
|
740
|
-
if (explainFnExistsRes.rowCount === 0) {
|
|
741
|
-
missingRequired.push("function postgres_ai.explain_generic exists");
|
|
742
|
-
} else {
|
|
743
|
-
const explainFnRes = await params.client.query(
|
|
744
|
-
`SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`,
|
|
745
|
-
true
|
|
746
|
-
);
|
|
747
|
-
if (!explainFnRes.rows?.[0]?.ok) {
|
|
748
|
-
missingRequired.push(
|
|
749
|
-
"EXECUTE on postgres_ai.explain_generic(text, text, text)"
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
736
|
const tableDescribeFnExistsRes = await params.client.query(
|
|
755
737
|
"SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
|
|
756
738
|
true
|
package/lib/util.ts
CHANGED
|
@@ -28,45 +28,185 @@ function isHtmlContent(text: string): boolean {
|
|
|
28
28
|
*/
|
|
29
29
|
const AUTH_REMEDIATION_HINT = "Run 'postgresai auth' to (re)authenticate, or set/update PGAI_API_KEY.";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Standard HTTP reason phrases we should NOT treat as a server-authored
|
|
33
|
+
* message: when the reason phrase equals the stock text for the status, it
|
|
34
|
+
* carries no extra information, so we prefer the friendlier generic label.
|
|
35
|
+
*/
|
|
36
|
+
const STANDARD_REASON_PHRASES: Record<number, string> = {
|
|
37
|
+
400: "Bad Request",
|
|
38
|
+
401: "Unauthorized",
|
|
39
|
+
403: "Forbidden",
|
|
40
|
+
404: "Not Found",
|
|
41
|
+
408: "Request Timeout",
|
|
42
|
+
409: "Conflict",
|
|
43
|
+
413: "Payload Too Large",
|
|
44
|
+
429: "Too Many Requests",
|
|
45
|
+
500: "Internal Server Error",
|
|
46
|
+
502: "Bad Gateway",
|
|
47
|
+
503: "Service Unavailable",
|
|
48
|
+
504: "Gateway Timeout",
|
|
49
|
+
};
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Format an HTTP error response into a clean, developer-friendly message.
|
|
33
53
|
* Handles HTML error pages (e.g., from Cloudflare) by showing just the status code and message.
|
|
34
54
|
* For 401 responses, appends a remediation hint pointing at `postgresai auth`.
|
|
55
|
+
*
|
|
56
|
+
* The platform's PostgREST layer uses the `PTxyz` custom-status convention:
|
|
57
|
+
* a raised `PT403`/`PT404`/… maps to the HTTP status and delivers the RPC's
|
|
58
|
+
* user-facing message in the HTTP **reason phrase** (`response.statusText`),
|
|
59
|
+
* NOT the JSON body — the body carries only `hint`/`details` (no `message`).
|
|
60
|
+
* So callers pass `statusText` and it is preferred over the built-in generic
|
|
61
|
+
* label. Behind an h2/h3 proxy the reason phrase is dropped entirely
|
|
62
|
+
* (`statusText` is empty), so the JSON body's `code`, `details`, and `hint`
|
|
63
|
+
* are all we get — they must be surfaced too (CLI half of
|
|
64
|
+
* https://gitlab.com/postgres-ai/platform-all/-/issues/537).
|
|
65
|
+
* Headline precedence: JSON body `message` → custom reason phrase → generic
|
|
66
|
+
* label, suffixed with the JSON `code` (e.g. `PT403`) when present; the JSON
|
|
67
|
+
* `details` (plural, PostgREST's spelling) is shown as a supplementary line,
|
|
68
|
+
* and the JSON `hint` (the remediation) as a trailing `Hint:` line.
|
|
35
69
|
*/
|
|
36
|
-
export function formatHttpError(
|
|
37
|
-
|
|
38
|
-
|
|
70
|
+
export function formatHttpError(
|
|
71
|
+
operation: string,
|
|
72
|
+
status: number,
|
|
73
|
+
responseBody?: string,
|
|
74
|
+
statusText?: string
|
|
75
|
+
): string {
|
|
76
|
+
const generic = HTTP_STATUS_MESSAGES[status] || "Request failed";
|
|
39
77
|
const remediation = status === 401 ? `\n${AUTH_REMEDIATION_HINT}` : "";
|
|
40
78
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return errMsg + remediation;
|
|
46
|
-
}
|
|
79
|
+
let bodyMessage: string | undefined;
|
|
80
|
+
let bodyDetails: string | undefined;
|
|
81
|
+
let bodyCode: string | undefined;
|
|
82
|
+
let bodyHint: string | undefined;
|
|
47
83
|
|
|
48
|
-
|
|
84
|
+
if (responseBody && !isHtmlContent(responseBody)) {
|
|
85
|
+
// If it's HTML (like Cloudflare error pages), we fall through with no
|
|
86
|
+
// parsed fields and never dump the raw HTML.
|
|
49
87
|
try {
|
|
50
88
|
const errObj = JSON.parse(responseBody);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
89
|
+
const message = errObj.message ?? errObj.error;
|
|
90
|
+
if (typeof message === "string" && message.trim().length > 0) {
|
|
91
|
+
bodyMessage = redactTextSecrets(message.trim());
|
|
92
|
+
}
|
|
93
|
+
// PostgREST spells it `details` (plural); accept `detail` too.
|
|
94
|
+
const details = errObj.details ?? errObj.detail;
|
|
95
|
+
if (typeof details === "string" && details.trim().length > 0) {
|
|
96
|
+
bodyDetails = redactTextSecrets(details.trim());
|
|
97
|
+
}
|
|
98
|
+
if (typeof errObj.code === "string" && errObj.code.trim().length > 0) {
|
|
99
|
+
bodyCode = errObj.code.trim();
|
|
100
|
+
}
|
|
101
|
+
if (typeof errObj.hint === "string" && errObj.hint.trim().length > 0) {
|
|
102
|
+
bodyHint = redactTextSecrets(errObj.hint.trim());
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
bodyMessage === undefined &&
|
|
106
|
+
bodyDetails === undefined &&
|
|
107
|
+
bodyCode === undefined &&
|
|
108
|
+
bodyHint === undefined
|
|
109
|
+
) {
|
|
110
|
+
// A JSON body with none of the known fields (message/error/details/
|
|
111
|
+
// detail/code/hint) still carries the only diagnostic there is —
|
|
112
|
+
// surface it (redacted, compact) instead of silently dropping it.
|
|
113
|
+
bodyDetails = redactSecretsForLog(JSON.stringify(errObj));
|
|
57
114
|
}
|
|
58
115
|
} catch {
|
|
59
|
-
// Plain text error -
|
|
116
|
+
// Plain text error - treat it as the details line if short and useful.
|
|
117
|
+
// Scrubbed: raw error bodies can echo credentials (e.g. a connStr) and
|
|
118
|
+
// this string ends up in thrown Errors / MCP isError responses.
|
|
60
119
|
const trimmed = responseBody.trim();
|
|
61
120
|
if (trimmed.length > 0 && trimmed.length < 500) {
|
|
62
|
-
|
|
121
|
+
bodyDetails = redactTextSecrets(trimmed);
|
|
63
122
|
}
|
|
64
123
|
}
|
|
65
124
|
}
|
|
66
125
|
|
|
126
|
+
// A custom reason phrase (PTxyz message) is meaningful only when it differs
|
|
127
|
+
// from the stock HTTP reason phrase for this status.
|
|
128
|
+
const trimmedReason = statusText?.trim();
|
|
129
|
+
const reasonPhrase =
|
|
130
|
+
trimmedReason &&
|
|
131
|
+
trimmedReason !== STANDARD_REASON_PHRASES[status] &&
|
|
132
|
+
trimmedReason !== generic
|
|
133
|
+
? trimmedReason
|
|
134
|
+
: undefined;
|
|
135
|
+
const safeReasonPhrase = reasonPhrase ? redactTextSecrets(reasonPhrase) : undefined;
|
|
136
|
+
|
|
137
|
+
const headline = bodyMessage ?? safeReasonPhrase ?? generic;
|
|
138
|
+
const codeSuffix = bodyCode && !headline.includes(bodyCode) ? ` (${bodyCode})` : "";
|
|
139
|
+
let errMsg = `${operation}: HTTP ${status} - ${headline}${codeSuffix}`;
|
|
140
|
+
if (bodyDetails && bodyDetails !== headline) {
|
|
141
|
+
errMsg += `\n${bodyDetails}`;
|
|
142
|
+
}
|
|
143
|
+
if (bodyHint) {
|
|
144
|
+
errMsg += `\nHint: ${bodyHint}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
67
147
|
return errMsg + remediation;
|
|
68
148
|
}
|
|
69
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Turn a low-level `fetch` failure into an actionable message. Node's fetch
|
|
152
|
+
* (undici) throws a `TypeError('fetch failed')` and stashes the real cause
|
|
153
|
+
* (`ECONNREFUSED`, DNS failure, `bad port`, TLS error, …) in `err.cause` — the
|
|
154
|
+
* opaque top-level message on its own tells the user nothing. This surfaces the
|
|
155
|
+
* cause and the URL that could not be reached, e.g.
|
|
156
|
+
* "Failed to list projects: could not reach http://127.0.0.1:1 (ECONNREFUSED)"
|
|
157
|
+
*/
|
|
158
|
+
export function describeFetchError(operation: string, url: string, err: unknown): string {
|
|
159
|
+
const cause = (err as { cause?: { code?: string; message?: string } } | null | undefined)?.cause;
|
|
160
|
+
const detail =
|
|
161
|
+
cause?.code ||
|
|
162
|
+
cause?.message ||
|
|
163
|
+
(err instanceof Error && err.message ? err.message : String(err));
|
|
164
|
+
return `${operation}: could not reach ${url} (${detail})`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* An HTTP error response with the status attached, so callers can classify
|
|
169
|
+
* retryability without string-matching the formatted message.
|
|
170
|
+
*/
|
|
171
|
+
export class HttpStatusError extends Error {
|
|
172
|
+
readonly status: number;
|
|
173
|
+
constructor(message: string, status: number) {
|
|
174
|
+
super(message);
|
|
175
|
+
this.name = "HttpStatusError";
|
|
176
|
+
this.status = status;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Transient statuses worth retrying/resuming on: server errors and rate limits. */
|
|
181
|
+
export function isRetryableHttpStatus(status: number): boolean {
|
|
182
|
+
return status >= 500 || status === 429;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Hard upper bound for a single platform request, even outside Joe polling. */
|
|
186
|
+
export const DEFAULT_HTTP_REQUEST_TIMEOUT_MS = 25_000;
|
|
187
|
+
|
|
188
|
+
export class HttpRequestTimeoutError extends Error {
|
|
189
|
+
constructor(operation: string, timeoutMs: number) {
|
|
190
|
+
super(`${operation}: request timed out after ${timeoutMs}ms`);
|
|
191
|
+
this.name = "HttpRequestTimeoutError";
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Return a finite AbortSignal timeout acceptable to both Node and Bun fetch. */
|
|
196
|
+
export function requestTimeoutSignal(timeoutMs?: number): { signal: AbortSignal; timeoutMs: number } {
|
|
197
|
+
const requested =
|
|
198
|
+
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
199
|
+
? timeoutMs
|
|
200
|
+
: DEFAULT_HTTP_REQUEST_TIMEOUT_MS;
|
|
201
|
+
const bounded = Math.max(1, Math.min(DEFAULT_HTTP_REQUEST_TIMEOUT_MS, Math.floor(requested)));
|
|
202
|
+
return { signal: AbortSignal.timeout(bounded), timeoutMs: bounded };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function isFetchTimeout(err: unknown): boolean {
|
|
206
|
+
const name = (err as { name?: unknown } | null)?.name;
|
|
207
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
208
|
+
}
|
|
209
|
+
|
|
70
210
|
export function maskSecret(secret: string): string {
|
|
71
211
|
if (!secret) return "";
|
|
72
212
|
if (secret.length <= 8) return "****";
|
|
@@ -74,6 +214,89 @@ export function maskSecret(secret: string): string {
|
|
|
74
214
|
return `${secret.slice(0, Math.min(12, secret.length - 8))}${"*".repeat(Math.max(4, secret.length - 16))}${secret.slice(-4)}`;
|
|
75
215
|
}
|
|
76
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Credential-bearing field names. Match complete normalized names rather than
|
|
219
|
+
* substrings: MCP results can contain ordinary SQL columns such as `author_id`,
|
|
220
|
+
* `token_type`, `tokens`, and `credited_at`, and corrupting those values is worse
|
|
221
|
+
* than leaving an unfamiliar key untouched. CamelCase is normalized so DBLab's
|
|
222
|
+
* `connStr` and `dbPassword` remain covered.
|
|
223
|
+
*/
|
|
224
|
+
function isSensitiveLogKey(key: string): boolean {
|
|
225
|
+
const normalized = key
|
|
226
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
227
|
+
.replace(/[-\s]+/g, "_")
|
|
228
|
+
.toLowerCase();
|
|
229
|
+
return /^(?:password|passwd|db_(?:pass|password)|conn_?str|secret|token|api_key|private_key|access_key|access_token|refresh_token|auth|auth_key|auth_token|authorization|credentials?|dsn)$/.test(normalized);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Return a deep copy with DBLab credential fields removed.
|
|
234
|
+
*
|
|
235
|
+
* Best-effort hygiene only, NOT a security barrier: redaction is key-name
|
|
236
|
+
* based, so secret VALUES under non-matching keys (e.g.
|
|
237
|
+
* `select rolpassword as x from pg_authid`) pass through untouched. The
|
|
238
|
+
* joe:exec scope and the org execution policy are the actual controls.
|
|
239
|
+
*/
|
|
240
|
+
export function redactSecrets(value: unknown): unknown {
|
|
241
|
+
if (Array.isArray(value)) {
|
|
242
|
+
return value.map(redactSecrets);
|
|
243
|
+
}
|
|
244
|
+
if (value && typeof value === "object") {
|
|
245
|
+
const out: Record<string, unknown> = {};
|
|
246
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
247
|
+
out[key] = isSensitiveLogKey(key) && child != null
|
|
248
|
+
? "[REDACTED]"
|
|
249
|
+
: redactSecrets(child);
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
return value;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Redact known credential fields from a serialized JSON payload before it is
|
|
258
|
+
* written to a debug log — the body-side counterpart of the `maskSecret`
|
|
259
|
+
* masking the `access-token` header already gets. Debug logging is reachable
|
|
260
|
+
* by MCP callers (`debug: true` is a caller-controlled tool argument), and
|
|
261
|
+
* DBLab bodies carry live credentials: the clone DB password rides in the clone
|
|
262
|
+
* create request (`data.db.password`), and clone create/status replies return
|
|
263
|
+
* the clone's `db.password` / `db.connStr`.
|
|
264
|
+
*
|
|
265
|
+
* A fixed placeholder is used instead of `maskSecret` because short passwords
|
|
266
|
+
* would leak most of their characters through partial masking. Non-JSON input
|
|
267
|
+
* cannot be redacted by key name, so it falls back to the pattern-based
|
|
268
|
+
* `redactTextSecrets` scrub — error paths (parse failures, plain-text error
|
|
269
|
+
* bodies) embed raw response text in thrown Errors that reach MCP `isError`
|
|
270
|
+
* responses and CLI stderr, and must not bypass redaction.
|
|
271
|
+
*/
|
|
272
|
+
export function redactSecretsForLog(text: string): string {
|
|
273
|
+
let parsed: unknown;
|
|
274
|
+
try {
|
|
275
|
+
parsed = JSON.parse(text);
|
|
276
|
+
} catch {
|
|
277
|
+
return redactTextSecrets(text);
|
|
278
|
+
}
|
|
279
|
+
return JSON.stringify(redactSecrets(parsed));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** URL userinfo credentials: `scheme://user:password@host` → password redacted. */
|
|
283
|
+
const TEXT_URL_USERINFO = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+):[^\s/@]+@/gi;
|
|
284
|
+
|
|
285
|
+
/** `key=value` / `key: value` / `"key": "value"` pairs for credential-named keys. */
|
|
286
|
+
const TEXT_SENSITIVE_PAIR =
|
|
287
|
+
/((?:password|passwd|db[-_]?pass|connstr|secret|token|api[-_]?key|private[-_]?key|access[-_]?key|cred(?:ential)?s?|dsn)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;&]+)/gi;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Best-effort scrub of credential-looking patterns in plain (non-JSON) text.
|
|
291
|
+
* Used for raw response text that ends up in thrown error messages, where the
|
|
292
|
+
* key-based `redactSecrets` cannot apply. Hygiene only — not an egress control.
|
|
293
|
+
*/
|
|
294
|
+
export function redactTextSecrets(text: string): string {
|
|
295
|
+
return text
|
|
296
|
+
.replace(TEXT_URL_USERINFO, "$1:[REDACTED]@")
|
|
297
|
+
.replace(TEXT_SENSITIVE_PAIR, "$1[REDACTED]");
|
|
298
|
+
}
|
|
299
|
+
|
|
77
300
|
|
|
78
301
|
export interface RootOptsLike {
|
|
79
302
|
apiBaseUrl?: string;
|
package/package.json
CHANGED
package/sql/06.helpers.sql
CHANGED
|
@@ -2,126 +2,6 @@
|
|
|
2
2
|
-- These functions use SECURITY DEFINER to allow the monitoring user to perform
|
|
3
3
|
-- operations they don't have direct permissions for.
|
|
4
4
|
|
|
5
|
-
/*
|
|
6
|
-
* explain_generic
|
|
7
|
-
*
|
|
8
|
-
* Function to get generic explain plans with optional HypoPG index testing.
|
|
9
|
-
* Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
|
|
10
|
-
*
|
|
11
|
-
* Security notes:
|
|
12
|
-
* - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
|
|
13
|
-
* - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
|
|
14
|
-
* - Input validation uses a simple heuristic to detect multiple statements
|
|
15
|
-
* (Note: may reject valid queries containing semicolons in string literals)
|
|
16
|
-
*
|
|
17
|
-
* Usage examples:
|
|
18
|
-
* -- Basic generic plan
|
|
19
|
-
* select postgres_ai.explain_generic('select * from users where id = $1');
|
|
20
|
-
*
|
|
21
|
-
* -- JSON format
|
|
22
|
-
* select postgres_ai.explain_generic('select * from users where id = $1', 'json');
|
|
23
|
-
*
|
|
24
|
-
* -- Test a hypothetical index
|
|
25
|
-
* select postgres_ai.explain_generic(
|
|
26
|
-
* 'select * from users where email = $1',
|
|
27
|
-
* 'text',
|
|
28
|
-
* 'create index on users (email)'
|
|
29
|
-
* );
|
|
30
|
-
*/
|
|
31
|
-
create or replace function postgres_ai.explain_generic(
|
|
32
|
-
in query text,
|
|
33
|
-
in format text default 'text',
|
|
34
|
-
in hypopg_index text default null,
|
|
35
|
-
out result text
|
|
36
|
-
)
|
|
37
|
-
language plpgsql
|
|
38
|
-
security definer
|
|
39
|
-
set search_path = pg_catalog, public
|
|
40
|
-
as $$
|
|
41
|
-
declare
|
|
42
|
-
v_line record;
|
|
43
|
-
v_lines text[] := '{}';
|
|
44
|
-
v_explain_query text;
|
|
45
|
-
v_hypo_result record;
|
|
46
|
-
v_version int;
|
|
47
|
-
v_hypopg_available boolean;
|
|
48
|
-
v_clean_query text;
|
|
49
|
-
begin
|
|
50
|
-
-- Check PostgreSQL version (generic_plan requires 16+)
|
|
51
|
-
select current_setting('server_version_num')::int into v_version;
|
|
52
|
-
|
|
53
|
-
if v_version < 160000 then
|
|
54
|
-
raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
|
|
55
|
-
current_setting('server_version');
|
|
56
|
-
end if;
|
|
57
|
-
|
|
58
|
-
-- Input validation: reject empty queries
|
|
59
|
-
if query is null or trim(query) = '' then
|
|
60
|
-
raise exception 'query cannot be empty';
|
|
61
|
-
end if;
|
|
62
|
-
|
|
63
|
-
-- Input validation: detect multiple statements (defense-in-depth)
|
|
64
|
-
-- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
|
|
65
|
-
-- Limitation: Queries with semicolons inside string literals will be rejected
|
|
66
|
-
v_clean_query := trim(query);
|
|
67
|
-
if v_clean_query like '%;%' then
|
|
68
|
-
-- Strip trailing semicolon if present (common user convenience)
|
|
69
|
-
v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
|
|
70
|
-
-- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
|
|
71
|
-
if v_clean_query like '%;%' then
|
|
72
|
-
raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
|
|
73
|
-
end if;
|
|
74
|
-
end if;
|
|
75
|
-
|
|
76
|
-
-- Check if HypoPG extension is available
|
|
77
|
-
if hypopg_index is not null then
|
|
78
|
-
select exists(
|
|
79
|
-
select 1 from pg_extension where extname = 'hypopg'
|
|
80
|
-
) into v_hypopg_available;
|
|
81
|
-
|
|
82
|
-
if not v_hypopg_available then
|
|
83
|
-
raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
|
|
84
|
-
end if;
|
|
85
|
-
|
|
86
|
-
-- Create hypothetical index
|
|
87
|
-
select * into v_hypo_result from hypopg_create_index(hypopg_index);
|
|
88
|
-
raise notice 'Created hypothetical index: % (oid: %)',
|
|
89
|
-
v_hypo_result.indexname, v_hypo_result.indexrelid;
|
|
90
|
-
end if;
|
|
91
|
-
|
|
92
|
-
-- Build and execute EXPLAIN query
|
|
93
|
-
-- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
|
|
94
|
-
begin
|
|
95
|
-
if lower(format) = 'json' then
|
|
96
|
-
execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
|
|
97
|
-
into result;
|
|
98
|
-
else
|
|
99
|
-
for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
|
|
100
|
-
v_lines := array_append(v_lines, v_line."QUERY PLAN");
|
|
101
|
-
end loop;
|
|
102
|
-
result := array_to_string(v_lines, e'\n');
|
|
103
|
-
end if;
|
|
104
|
-
exception when others then
|
|
105
|
-
-- Clean up hypothetical index before re-raising
|
|
106
|
-
if hypopg_index is not null then
|
|
107
|
-
perform hypopg_reset();
|
|
108
|
-
end if;
|
|
109
|
-
raise;
|
|
110
|
-
end;
|
|
111
|
-
|
|
112
|
-
-- Clean up hypothetical index
|
|
113
|
-
if hypopg_index is not null then
|
|
114
|
-
perform hypopg_reset();
|
|
115
|
-
end if;
|
|
116
|
-
end;
|
|
117
|
-
$$;
|
|
118
|
-
|
|
119
|
-
comment on function postgres_ai.explain_generic(text, text, text) is
|
|
120
|
-
'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
|
|
121
|
-
|
|
122
|
-
-- Grant execute to the monitoring user
|
|
123
|
-
grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
|
|
124
|
-
|
|
125
5
|
/*
|
|
126
6
|
* table_describe
|
|
127
7
|
*
|
|
@@ -435,5 +315,3 @@ comment on function postgres_ai.table_describe(text) is
|
|
|
435
315
|
'Returns comprehensive table information in compact text format for LLM analysis';
|
|
436
316
|
|
|
437
317
|
grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
|
|
438
|
-
|
|
439
|
-
|
package/test/auth.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join, resolve } from "path";
|
|
3
5
|
|
|
4
6
|
import * as util from "../lib/util";
|
|
5
7
|
import * as pkce from "../lib/pkce";
|
|
@@ -212,6 +214,33 @@ describe("Auth callback server", () => {
|
|
|
212
214
|
});
|
|
213
215
|
|
|
214
216
|
describe("CLI auth commands", () => {
|
|
217
|
+
test("cli: login --help shows all options", () => {
|
|
218
|
+
const r = runCli(["login", "--help"]);
|
|
219
|
+
expect(r.status).toBe(0);
|
|
220
|
+
expect(r.stdout).toMatch(/--set-key/);
|
|
221
|
+
expect(r.stdout).toMatch(/--debug/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("cli: login --set-key aliases auth login", () => {
|
|
225
|
+
const home = mkdtempSync(join(tmpdir(), "postgresai-login-"));
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const r = runCli(["login", "--set-key", "test-token"], {
|
|
229
|
+
HOME: home,
|
|
230
|
+
XDG_CONFIG_HOME: join(home, ".config"),
|
|
231
|
+
});
|
|
232
|
+
expect(r.status).toBe(0);
|
|
233
|
+
expect(r.stdout).toMatch(/API key saved/);
|
|
234
|
+
|
|
235
|
+
const saved = JSON.parse(
|
|
236
|
+
readFileSync(join(home, ".config", "postgresai", "config.json"), "utf8")
|
|
237
|
+
);
|
|
238
|
+
expect(saved.apiKey).toBe("test-token");
|
|
239
|
+
} finally {
|
|
240
|
+
rmSync(home, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
215
244
|
test("cli: auth login --help shows all options", () => {
|
|
216
245
|
const r = runCli(["auth", "login", "--help"]);
|
|
217
246
|
expect(r.status).toBe(0);
|
|
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
|
|
|
14
14
|
import Ajv2020 from "ajv/dist/2020";
|
|
15
15
|
|
|
16
16
|
import * as checkup from "../lib/checkup";
|
|
17
|
+
import { checkCurrentUserPermissions } from "../lib/init";
|
|
17
18
|
|
|
18
19
|
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
19
20
|
const schemasDir = resolve(import.meta.dir, "../../reporter/schemas");
|
|
@@ -191,6 +192,36 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
|
|
|
191
192
|
// Test all checks supported by express mode
|
|
192
193
|
const expressChecks = Object.keys(checkup.CHECK_INFO);
|
|
193
194
|
|
|
195
|
+
test("vanilla database preflight is non-fatal and full CLI output marks F004/F005 degraded", async () => {
|
|
196
|
+
const permissions = await checkCurrentUserPermissions(client);
|
|
197
|
+
expect(permissions.ok).toBe(true);
|
|
198
|
+
expect(permissions.missingOptional.some(
|
|
199
|
+
(row) => row.permission_name === "postgres_ai schema exists"
|
|
200
|
+
)).toBe(true);
|
|
201
|
+
|
|
202
|
+
const connString = `postgresql://postgres@localhost:${pg.port}/postgres?host=${encodeURIComponent(pg.socketDir)}`;
|
|
203
|
+
const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
204
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
205
|
+
const result = Bun.spawnSync(
|
|
206
|
+
[bunBin, cliPath, "checkup", connString, "--no-upload", "--json"],
|
|
207
|
+
{ env: { ...process.env, XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" } }
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
211
|
+
if (result.exitCode !== 0) {
|
|
212
|
+
throw new Error(`CLI exited ${result.exitCode}: ${stderr}`);
|
|
213
|
+
}
|
|
214
|
+
const reports = JSON.parse(new TextDecoder().decode(result.stdout));
|
|
215
|
+
expect(Object.keys(reports)).toHaveLength(17);
|
|
216
|
+
expect(stderr).toContain("optional: postgres_ai schema not found");
|
|
217
|
+
for (const checkId of ["F004", "F005"]) {
|
|
218
|
+
const dbEntry = reports[checkId].results["node-01"].data.postgres;
|
|
219
|
+
expect(dbEntry.status.ok).toBe(false);
|
|
220
|
+
expect(dbEntry.status.reason).toBe("missing_schema");
|
|
221
|
+
expect(dbEntry.status.error).toMatch(/schema "postgres_ai" does not exist/i);
|
|
222
|
+
}
|
|
223
|
+
}, { timeout: 60000 });
|
|
224
|
+
|
|
194
225
|
for (const checkId of expressChecks) {
|
|
195
226
|
test(`${checkId} report validates against shared schema`, async () => {
|
|
196
227
|
const generator = checkup.REPORT_GENERATORS[checkId];
|
|
@@ -379,25 +410,4 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
|
|
|
379
410
|
}
|
|
380
411
|
});
|
|
381
412
|
|
|
382
|
-
test("CLI --markdown flag works without API key", async () => {
|
|
383
|
-
// Test that --markdown works even without an API key
|
|
384
|
-
const connString = `postgresql://postgres@${pg.socketDir}:${pg.port}/postgres`;
|
|
385
|
-
const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
386
|
-
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
387
|
-
|
|
388
|
-
const result = Bun.spawnSync(
|
|
389
|
-
[bunBin, cliPath, "checkup", connString, "--check-id", "H002", "--markdown", "--no-upload"],
|
|
390
|
-
{
|
|
391
|
-
env: {
|
|
392
|
-
...process.env,
|
|
393
|
-
XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config",
|
|
394
|
-
},
|
|
395
|
-
}
|
|
396
|
-
);
|
|
397
|
-
|
|
398
|
-
const stderr = new TextDecoder().decode(result.stderr);
|
|
399
|
-
|
|
400
|
-
// Should not complain about missing API key
|
|
401
|
-
expect(stderr).not.toMatch(/API key is required/i);
|
|
402
|
-
});
|
|
403
413
|
});
|
package/test/checkup.test.ts
CHANGED
|
@@ -1539,12 +1539,13 @@ describe("CLI tests", () => {
|
|
|
1539
1539
|
const r = runCli(["checkup", "--help"]);
|
|
1540
1540
|
expect(r.status).toBe(0);
|
|
1541
1541
|
expect(r.stdout).toMatch(/--markdown/);
|
|
1542
|
-
expect(r.stdout).toMatch(/
|
|
1542
|
+
expect(r.stdout).toMatch(/PostgresAI API/i);
|
|
1543
|
+
expect(r.stdout).toMatch(/transmits the full\s+report JSON/i);
|
|
1543
1544
|
});
|
|
1544
1545
|
|
|
1545
1546
|
test("checkup --markdown is recognized as valid option", () => {
|
|
1546
1547
|
// Should not produce "unknown option" error for --markdown
|
|
1547
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"
|
|
1548
|
+
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"]);
|
|
1548
1549
|
// Connection will fail, but option parsing should succeed
|
|
1549
1550
|
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1550
1551
|
expect(r.stderr).not.toMatch(/did you mean/i);
|
|
@@ -1554,11 +1555,13 @@ describe("CLI tests", () => {
|
|
|
1554
1555
|
// Use empty config dir to ensure no API key is configured
|
|
1555
1556
|
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1556
1557
|
// --markdown should work even without API key
|
|
1557
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"
|
|
1558
|
+
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"], env);
|
|
1558
1559
|
// Connection will fail, but --markdown flag should be recognized
|
|
1559
1560
|
expect(r.status).not.toBe(0);
|
|
1560
1561
|
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1561
1562
|
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1563
|
+
expect(r.stderr).toMatch(/full report JSON will still be sent/i);
|
|
1564
|
+
expect(r.stderr).toMatch(/markdown conversion/i);
|
|
1562
1565
|
});
|
|
1563
1566
|
|
|
1564
1567
|
test("checkup with --no-upload and no output flags shows summary", () => {
|
|
@@ -1663,6 +1666,21 @@ describe("checkup auth pre-flight (CLI)", () => {
|
|
|
1663
1666
|
// failure mentions this address.
|
|
1664
1667
|
const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
|
|
1665
1668
|
|
|
1669
|
+
test("--no-upload rejects --markdown before database or API work", () => {
|
|
1670
|
+
const env = {
|
|
1671
|
+
XDG_CONFIG_HOME: `/tmp/postgresai-test-no-upload-markdown-${process.pid}`,
|
|
1672
|
+
PGAI_API_KEY: "configured-token",
|
|
1673
|
+
PGAI_API_BASE_URL: "http://127.0.0.1:1",
|
|
1674
|
+
};
|
|
1675
|
+
const r = runCli(["checkup", DEAD_DB, "--no-upload", "--markdown"], env);
|
|
1676
|
+
|
|
1677
|
+
expect(r.status).not.toBe(0);
|
|
1678
|
+
expect(r.stderr).toMatch(/--no-upload and --markdown are mutually exclusive/i);
|
|
1679
|
+
expect(r.stderr).toMatch(/markdown conversion is performed by the PostgresAI API/i);
|
|
1680
|
+
expect(r.stderr).toMatch(/use --json or --output/i);
|
|
1681
|
+
expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1666
1684
|
test("no API key: prominent notice, run continues locally", () => {
|
|
1667
1685
|
const env = {
|
|
1668
1686
|
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
|
|
@@ -2173,6 +2191,31 @@ describe("checkup-summary", () => {
|
|
|
2173
2191
|
);
|
|
2174
2192
|
});
|
|
2175
2193
|
|
|
2194
|
+
for (const checkId of ["F004", "F005"]) {
|
|
2195
|
+
test(`generateCheckSummary for degraded ${checkId} is never ok`, () => {
|
|
2196
|
+
const report = {
|
|
2197
|
+
results: {
|
|
2198
|
+
node1: {
|
|
2199
|
+
data: {
|
|
2200
|
+
db1: {
|
|
2201
|
+
status: {
|
|
2202
|
+
ok: false,
|
|
2203
|
+
reason: "missing_schema",
|
|
2204
|
+
error: 'schema "postgres_ai" does not exist',
|
|
2205
|
+
},
|
|
2206
|
+
total_count: 0,
|
|
2207
|
+
},
|
|
2208
|
+
},
|
|
2209
|
+
},
|
|
2210
|
+
},
|
|
2211
|
+
};
|
|
2212
|
+
|
|
2213
|
+
const result = summary.generateCheckSummary(checkId, report);
|
|
2214
|
+
expect(result.status).toBe("warning");
|
|
2215
|
+
expect(result.message).toMatch(/degraded.*missing schema/i);
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2176
2219
|
test("generateCheckSummary for H001 with no issues", () => {
|
|
2177
2220
|
const report = {
|
|
2178
2221
|
results: {
|
|
@@ -3371,6 +3414,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
|
|
|
3371
3414
|
autovacuum_vacuum_scale_factor: expectedAutovacuumSetting,
|
|
3372
3415
|
});
|
|
3373
3416
|
expect(reports.F004.results["test-node"].data.testdb).toEqual({
|
|
3417
|
+
status: { ok: true, reason: null, error: null },
|
|
3374
3418
|
bloated_tables: [],
|
|
3375
3419
|
total_count: 0,
|
|
3376
3420
|
total_bloat_size_bytes: 0,
|
|
@@ -3379,6 +3423,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
|
|
|
3379
3423
|
database_size_pretty: "1.00 GiB",
|
|
3380
3424
|
});
|
|
3381
3425
|
expect(reports.F005.results["test-node"].data.testdb).toEqual({
|
|
3426
|
+
status: { ok: true, reason: null, error: null },
|
|
3382
3427
|
bloated_indexes: [],
|
|
3383
3428
|
total_count: 0,
|
|
3384
3429
|
total_bloat_size_bytes: 0,
|