postgresai 0.14.0-dev.56 → 0.14.0-dev.58

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.
@@ -3,11 +3,17 @@
3
3
  -- operations they don't have direct permissions for.
4
4
 
5
5
  /*
6
- * pgai_explain_generic
6
+ * explain_generic
7
7
  *
8
8
  * Function to get generic explain plans with optional HypoPG index testing.
9
9
  * Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
10
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
+ *
11
17
  * Usage examples:
12
18
  * -- Basic generic plan
13
19
  * select postgres_ai.explain_generic('select * from users where id = $1');
@@ -39,6 +45,7 @@ declare
39
45
  v_hypo_result record;
40
46
  v_version int;
41
47
  v_hypopg_available boolean;
48
+ v_clean_query text;
42
49
  begin
43
50
  -- Check PostgreSQL version (generic_plan requires 16+)
44
51
  select current_setting('server_version_num')::int into v_version;
@@ -48,6 +55,24 @@ begin
48
55
  current_setting('server_version');
49
56
  end if;
50
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
+
51
76
  -- Check if HypoPG extension is available
52
77
  if hypopg_index is not null then
53
78
  select exists(
@@ -64,15 +89,14 @@ begin
64
89
  v_hypo_result.indexname, v_hypo_result.indexrelid;
65
90
  end if;
66
91
 
67
- -- Build and execute explain query based on format
68
- -- Output is preserved exactly as EXPLAIN returns it
92
+ -- Build and execute EXPLAIN query
93
+ -- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
69
94
  begin
70
95
  if lower(format) = 'json' then
71
- v_explain_query := 'explain (verbose, settings, generic_plan, format json) ' || query;
72
- execute v_explain_query into result;
96
+ execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
97
+ into result;
73
98
  else
74
- v_explain_query := 'explain (verbose, settings, generic_plan) ' || query;
75
- for v_line in execute v_explain_query loop
99
+ for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
76
100
  v_lines := array_append(v_lines, v_line."QUERY PLAN");
77
101
  end loop;
78
102
  result := array_to_string(v_lines, e'\n');
@@ -3,11 +3,17 @@
3
3
  -- operations they don't have direct permissions for.
4
4
 
5
5
  /*
6
- * pgai_explain_generic
6
+ * explain_generic
7
7
  *
8
8
  * Function to get generic explain plans with optional HypoPG index testing.
9
9
  * Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
10
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
+ *
11
17
  * Usage examples:
12
18
  * -- Basic generic plan
13
19
  * select postgres_ai.explain_generic('select * from users where id = $1');
@@ -39,6 +45,7 @@ declare
39
45
  v_hypo_result record;
40
46
  v_version int;
41
47
  v_hypopg_available boolean;
48
+ v_clean_query text;
42
49
  begin
43
50
  -- Check PostgreSQL version (generic_plan requires 16+)
44
51
  select current_setting('server_version_num')::int into v_version;
@@ -48,6 +55,24 @@ begin
48
55
  current_setting('server_version');
49
56
  end if;
50
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
+
51
76
  -- Check if HypoPG extension is available
52
77
  if hypopg_index is not null then
53
78
  select exists(
@@ -64,15 +89,14 @@ begin
64
89
  v_hypo_result.indexname, v_hypo_result.indexrelid;
65
90
  end if;
66
91
 
67
- -- Build and execute explain query based on format
68
- -- Output is preserved exactly as EXPLAIN returns it
92
+ -- Build and execute EXPLAIN query
93
+ -- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
69
94
  begin
70
95
  if lower(format) = 'json' then
71
- v_explain_query := 'explain (verbose, settings, generic_plan, format json) ' || query;
72
- execute v_explain_query into result;
96
+ execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
97
+ into result;
73
98
  else
74
- v_explain_query := 'explain (verbose, settings, generic_plan) ' || query;
75
- for v_line in execute v_explain_query loop
99
+ for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
76
100
  v_lines := array_append(v_lines, v_line."QUERY PLAN");
77
101
  end loop;
78
102
  result := array_to_string(v_lines, e'\n');
package/lib/config.ts CHANGED
@@ -56,10 +56,10 @@ export function readConfig(): Config {
56
56
  try {
57
57
  const content = fs.readFileSync(userConfigPath, "utf8");
58
58
  const parsed = JSON.parse(content);
59
- config.apiKey = parsed.apiKey || null;
60
- config.baseUrl = parsed.baseUrl || null;
61
- config.orgId = parsed.orgId || null;
62
- config.defaultProject = parsed.defaultProject || null;
59
+ config.apiKey = parsed.apiKey ?? null;
60
+ config.baseUrl = parsed.baseUrl ?? null;
61
+ config.orgId = parsed.orgId ?? null;
62
+ config.defaultProject = parsed.defaultProject ?? null;
63
63
  return config;
64
64
  } catch (err) {
65
65
  const message = err instanceof Error ? err.message : String(err);
package/lib/issues.ts CHANGED
@@ -1,5 +1,16 @@
1
1
  import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
2
2
 
3
+ /**
4
+ * Issue status constants.
5
+ * Used in updateIssue to change issue state.
6
+ */
7
+ export const IssueStatus = {
8
+ /** Issue is open and active */
9
+ OPEN: 0,
10
+ /** Issue is closed/resolved */
11
+ CLOSED: 1,
12
+ } as const;
13
+
3
14
  export interface IssueActionItem {
4
15
  id: string;
5
16
  issue_id: string;
@@ -69,6 +80,7 @@ export async function fetchIssues(params: FetchIssuesParams): Promise<IssueListI
69
80
  "access-token": apiKey,
70
81
  "Prefer": "return=representation",
71
82
  "Content-Type": "application/json",
83
+ "Connection": "close",
72
84
  };
73
85
 
74
86
  if (debug) {
@@ -126,6 +138,7 @@ export async function fetchIssueComments(params: FetchIssueCommentsParams): Prom
126
138
  "access-token": apiKey,
127
139
  "Prefer": "return=representation",
128
140
  "Content-Type": "application/json",
141
+ "Connection": "close",
129
142
  };
130
143
 
131
144
  if (debug) {
@@ -185,6 +198,7 @@ export async function fetchIssue(params: FetchIssueParams): Promise<IssueDetail
185
198
  "access-token": apiKey,
186
199
  "Prefer": "return=representation",
187
200
  "Content-Type": "application/json",
201
+ "Connection": "close",
188
202
  };
189
203
 
190
204
  if (debug) {
@@ -223,6 +237,112 @@ export async function fetchIssue(params: FetchIssueParams): Promise<IssueDetail
223
237
  }
224
238
  }
225
239
 
240
+ export interface CreateIssueParams {
241
+ apiKey: string;
242
+ apiBaseUrl: string;
243
+ title: string;
244
+ orgId: number;
245
+ description?: string;
246
+ projectId?: number;
247
+ labels?: string[];
248
+ debug?: boolean;
249
+ }
250
+
251
+ export interface CreatedIssue {
252
+ id: string;
253
+ title: string;
254
+ description: string | null;
255
+ created_at: string;
256
+ status: number;
257
+ project_id: number | null;
258
+ labels: string[] | null;
259
+ }
260
+
261
+ /**
262
+ * Create a new issue in the PostgresAI platform.
263
+ *
264
+ * @param params - The parameters for creating an issue
265
+ * @param params.apiKey - API key for authentication
266
+ * @param params.apiBaseUrl - Base URL for the API
267
+ * @param params.title - Issue title (required)
268
+ * @param params.orgId - Organization ID (required)
269
+ * @param params.description - Optional issue description
270
+ * @param params.projectId - Optional project ID to associate with
271
+ * @param params.labels - Optional array of label strings
272
+ * @param params.debug - Enable debug logging
273
+ * @returns The created issue object
274
+ * @throws Error if API key, title, or orgId is missing, or if the API call fails
275
+ */
276
+ export async function createIssue(params: CreateIssueParams): Promise<CreatedIssue> {
277
+ const { apiKey, apiBaseUrl, title, orgId, description, projectId, labels, debug } = params;
278
+ if (!apiKey) {
279
+ throw new Error("API key is required");
280
+ }
281
+ if (!title) {
282
+ throw new Error("title is required");
283
+ }
284
+ if (typeof orgId !== "number") {
285
+ throw new Error("orgId is required");
286
+ }
287
+
288
+ const base = normalizeBaseUrl(apiBaseUrl);
289
+ const url = new URL(`${base}/rpc/issue_create`);
290
+
291
+ const bodyObj: Record<string, unknown> = {
292
+ title: title,
293
+ org_id: orgId,
294
+ };
295
+ if (description !== undefined) {
296
+ bodyObj.description = description;
297
+ }
298
+ if (projectId !== undefined) {
299
+ bodyObj.project_id = projectId;
300
+ }
301
+ if (labels && labels.length > 0) {
302
+ bodyObj.labels = labels;
303
+ }
304
+ const body = JSON.stringify(bodyObj);
305
+
306
+ const headers: Record<string, string> = {
307
+ "access-token": apiKey,
308
+ "Prefer": "return=representation",
309
+ "Content-Type": "application/json",
310
+ "Connection": "close",
311
+ };
312
+
313
+ if (debug) {
314
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
315
+ console.log(`Debug: Resolved API base URL: ${base}`);
316
+ console.log(`Debug: POST URL: ${url.toString()}`);
317
+ console.log(`Debug: Auth scheme: access-token`);
318
+ console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
319
+ console.log(`Debug: Request body: ${body}`);
320
+ }
321
+
322
+ const response = await fetch(url.toString(), {
323
+ method: "POST",
324
+ headers,
325
+ body,
326
+ });
327
+
328
+ if (debug) {
329
+ console.log(`Debug: Response status: ${response.status}`);
330
+ console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
331
+ }
332
+
333
+ const data = await response.text();
334
+
335
+ if (response.ok) {
336
+ try {
337
+ return JSON.parse(data) as CreatedIssue;
338
+ } catch {
339
+ throw new Error(`Failed to parse create issue response: ${data}`);
340
+ }
341
+ } else {
342
+ throw new Error(formatHttpError("Failed to create issue", response.status, data));
343
+ }
344
+ }
345
+
226
346
  export interface CreateIssueCommentParams {
227
347
  apiKey: string;
228
348
  apiBaseUrl: string;
@@ -260,6 +380,7 @@ export async function createIssueComment(params: CreateIssueCommentParams): Prom
260
380
  "access-token": apiKey,
261
381
  "Prefer": "return=representation",
262
382
  "Content-Type": "application/json",
383
+ "Connection": "close",
263
384
  };
264
385
 
265
386
  if (debug) {
@@ -294,3 +415,200 @@ export async function createIssueComment(params: CreateIssueCommentParams): Prom
294
415
  throw new Error(formatHttpError("Failed to create issue comment", response.status, data));
295
416
  }
296
417
  }
418
+
419
+ export interface UpdateIssueParams {
420
+ apiKey: string;
421
+ apiBaseUrl: string;
422
+ issueId: string;
423
+ title?: string;
424
+ description?: string;
425
+ status?: number;
426
+ labels?: string[];
427
+ debug?: boolean;
428
+ }
429
+
430
+ export interface UpdatedIssue {
431
+ id: string;
432
+ title: string;
433
+ description: string | null;
434
+ status: number;
435
+ updated_at: string;
436
+ labels: string[] | null;
437
+ }
438
+
439
+ /**
440
+ * Update an existing issue in the PostgresAI platform.
441
+ *
442
+ * @param params - The parameters for updating an issue
443
+ * @param params.apiKey - API key for authentication
444
+ * @param params.apiBaseUrl - Base URL for the API
445
+ * @param params.issueId - ID of the issue to update (required)
446
+ * @param params.title - New title (optional)
447
+ * @param params.description - New description (optional)
448
+ * @param params.status - New status: 0 = open, 1 = closed (optional)
449
+ * @param params.labels - New labels array (optional, replaces existing)
450
+ * @param params.debug - Enable debug logging
451
+ * @returns The updated issue object
452
+ * @throws Error if API key or issueId is missing, if no fields to update are provided, or if the API call fails
453
+ */
454
+ export async function updateIssue(params: UpdateIssueParams): Promise<UpdatedIssue> {
455
+ const { apiKey, apiBaseUrl, issueId, title, description, status, labels, debug } = params;
456
+ if (!apiKey) {
457
+ throw new Error("API key is required");
458
+ }
459
+ if (!issueId) {
460
+ throw new Error("issueId is required");
461
+ }
462
+ if (title === undefined && description === undefined && status === undefined && labels === undefined) {
463
+ throw new Error("At least one field to update is required (title, description, status, or labels)");
464
+ }
465
+
466
+ const base = normalizeBaseUrl(apiBaseUrl);
467
+ const url = new URL(`${base}/rpc/issue_update`);
468
+
469
+ // Prod RPC expects p_* argument names (see OpenAPI at /api/general/).
470
+ const bodyObj: Record<string, unknown> = {
471
+ p_id: issueId,
472
+ };
473
+ if (title !== undefined) {
474
+ bodyObj.p_title = title;
475
+ }
476
+ if (description !== undefined) {
477
+ bodyObj.p_description = description;
478
+ }
479
+ if (status !== undefined) {
480
+ bodyObj.p_status = status;
481
+ }
482
+ if (labels !== undefined) {
483
+ bodyObj.p_labels = labels;
484
+ }
485
+ const body = JSON.stringify(bodyObj);
486
+
487
+ const headers: Record<string, string> = {
488
+ "access-token": apiKey,
489
+ "Prefer": "return=representation",
490
+ "Content-Type": "application/json",
491
+ "Connection": "close",
492
+ };
493
+
494
+ if (debug) {
495
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
496
+ console.log(`Debug: Resolved API base URL: ${base}`);
497
+ console.log(`Debug: POST URL: ${url.toString()}`);
498
+ console.log(`Debug: Auth scheme: access-token`);
499
+ console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
500
+ console.log(`Debug: Request body: ${body}`);
501
+ }
502
+
503
+ const response = await fetch(url.toString(), {
504
+ method: "POST",
505
+ headers,
506
+ body,
507
+ });
508
+
509
+ if (debug) {
510
+ console.log(`Debug: Response status: ${response.status}`);
511
+ console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
512
+ }
513
+
514
+ const data = await response.text();
515
+
516
+ if (response.ok) {
517
+ try {
518
+ return JSON.parse(data) as UpdatedIssue;
519
+ } catch {
520
+ throw new Error(`Failed to parse update issue response: ${data}`);
521
+ }
522
+ } else {
523
+ throw new Error(formatHttpError("Failed to update issue", response.status, data));
524
+ }
525
+ }
526
+
527
+ export interface UpdateIssueCommentParams {
528
+ apiKey: string;
529
+ apiBaseUrl: string;
530
+ commentId: string;
531
+ content: string;
532
+ debug?: boolean;
533
+ }
534
+
535
+ export interface UpdatedIssueComment {
536
+ id: string;
537
+ issue_id: string;
538
+ content: string;
539
+ updated_at: string;
540
+ }
541
+
542
+ /**
543
+ * Update an existing issue comment in the PostgresAI platform.
544
+ *
545
+ * @param params - The parameters for updating a comment
546
+ * @param params.apiKey - API key for authentication
547
+ * @param params.apiBaseUrl - Base URL for the API
548
+ * @param params.commentId - ID of the comment to update (required)
549
+ * @param params.content - New comment content (required)
550
+ * @param params.debug - Enable debug logging
551
+ * @returns The updated comment object
552
+ * @throws Error if API key, commentId, or content is missing, or if the API call fails
553
+ */
554
+ export async function updateIssueComment(params: UpdateIssueCommentParams): Promise<UpdatedIssueComment> {
555
+ const { apiKey, apiBaseUrl, commentId, content, debug } = params;
556
+ if (!apiKey) {
557
+ throw new Error("API key is required");
558
+ }
559
+ if (!commentId) {
560
+ throw new Error("commentId is required");
561
+ }
562
+ if (!content) {
563
+ throw new Error("content is required");
564
+ }
565
+
566
+ const base = normalizeBaseUrl(apiBaseUrl);
567
+ const url = new URL(`${base}/rpc/issue_comment_update`);
568
+
569
+ const bodyObj: Record<string, unknown> = {
570
+ // Prod RPC expects p_* argument names (see OpenAPI at /api/general/).
571
+ p_id: commentId,
572
+ p_content: content,
573
+ };
574
+ const body = JSON.stringify(bodyObj);
575
+
576
+ const headers: Record<string, string> = {
577
+ "access-token": apiKey,
578
+ "Prefer": "return=representation",
579
+ "Content-Type": "application/json",
580
+ "Connection": "close",
581
+ };
582
+
583
+ if (debug) {
584
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
585
+ console.log(`Debug: Resolved API base URL: ${base}`);
586
+ console.log(`Debug: POST URL: ${url.toString()}`);
587
+ console.log(`Debug: Auth scheme: access-token`);
588
+ console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
589
+ console.log(`Debug: Request body: ${body}`);
590
+ }
591
+
592
+ const response = await fetch(url.toString(), {
593
+ method: "POST",
594
+ headers,
595
+ body,
596
+ });
597
+
598
+ if (debug) {
599
+ console.log(`Debug: Response status: ${response.status}`);
600
+ console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
601
+ }
602
+
603
+ const data = await response.text();
604
+
605
+ if (response.ok) {
606
+ try {
607
+ return JSON.parse(data) as UpdatedIssueComment;
608
+ } catch {
609
+ throw new Error(`Failed to parse update comment response: ${data}`);
610
+ }
611
+ } else {
612
+ throw new Error(formatHttpError("Failed to update issue comment", response.status, data));
613
+ }
614
+ }