postgresai 0.14.0 → 0.15.0-dev.10

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/lib/util.ts CHANGED
@@ -70,15 +70,18 @@ export function maskSecret(secret: string): string {
70
70
  export interface RootOptsLike {
71
71
  apiBaseUrl?: string;
72
72
  uiBaseUrl?: string;
73
+ storageBaseUrl?: string;
73
74
  }
74
75
 
75
76
  export interface ConfigLike {
76
77
  baseUrl?: string | null;
78
+ storageBaseUrl?: string | null;
77
79
  }
78
80
 
79
81
  export interface ResolvedBaseUrls {
80
82
  apiBaseUrl: string;
81
83
  uiBaseUrl: string;
84
+ storageBaseUrl: string;
82
85
  }
83
86
 
84
87
  /**
@@ -105,17 +108,20 @@ export function normalizeBaseUrl(value: string): string {
105
108
  export function resolveBaseUrls(
106
109
  opts?: RootOptsLike,
107
110
  cfg?: ConfigLike,
108
- defaults: { apiBaseUrl?: string; uiBaseUrl?: string } = {}
111
+ defaults: { apiBaseUrl?: string; uiBaseUrl?: string; storageBaseUrl?: string } = {}
109
112
  ): ResolvedBaseUrls {
110
113
  const defApi = defaults.apiBaseUrl || "https://postgres.ai/api/general/";
111
114
  const defUi = defaults.uiBaseUrl || "https://console.postgres.ai";
115
+ const defStorage = defaults.storageBaseUrl || "https://postgres.ai/storage";
112
116
 
113
117
  const apiCandidate = (opts?.apiBaseUrl || process.env.PGAI_API_BASE_URL || cfg?.baseUrl || defApi) as string;
114
118
  const uiCandidate = (opts?.uiBaseUrl || process.env.PGAI_UI_BASE_URL || defUi) as string;
119
+ const storageCandidate = (opts?.storageBaseUrl || process.env.PGAI_STORAGE_BASE_URL || cfg?.storageBaseUrl || defStorage) as string;
115
120
 
116
121
  return {
117
122
  apiBaseUrl: normalizeBaseUrl(apiCandidate),
118
123
  uiBaseUrl: normalizeBaseUrl(uiCandidate),
124
+ storageBaseUrl: normalizeBaseUrl(storageCandidate),
119
125
  };
120
126
  }
121
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0",
3
+ "version": "0.15.0-dev.10",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -28,7 +28,7 @@
28
28
  "embed-metrics": "bun run scripts/embed-metrics.ts",
29
29
  "embed-checkup-dictionary": "bun run scripts/embed-checkup-dictionary.ts",
30
30
  "embed-all": "bun run embed-metrics && bun run embed-checkup-dictionary",
31
- "build": "bun run embed-all && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql",
31
+ "build": "bun run embed-all && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql && cp ../instances.demo.yml ./instances.demo.yml",
32
32
  "prepublishOnly": "npm run build",
33
33
  "start": "bun ./bin/postgres-ai.ts --help",
34
34
  "start:node": "node ./dist/bin/postgres-ai.js --help",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@modelcontextprotocol/sdk": "^1.20.2",
44
- "commander": "^12.1.0",
44
+ "commander": "^14.0.3",
45
45
  "js-yaml": "^4.1.0",
46
46
  "pg": "^8.16.3"
47
47
  },
@@ -51,7 +51,7 @@
51
51
  "@types/pg": "^8.15.6",
52
52
  "ajv": "^8.17.1",
53
53
  "ajv-formats": "^3.0.1",
54
- "typescript": "^5.9.3"
54
+ "typescript": "^6.0.2"
55
55
  },
56
56
  "publishConfig": {
57
57
  "access": "public"
@@ -51,7 +51,16 @@ function generateTypeScript(data: CheckupDictionaryEntry[], sourceUrl: string):
51
51
  return lines.join("\n");
52
52
  }
53
53
 
54
+ // Allowed hosts for fetch requests to prevent SSRF
55
+ const ALLOWED_HOSTS = ["postgres.ai"];
56
+
54
57
  async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
58
+ // Validate URL against allowlist to prevent SSRF
59
+ const parsed = new URL(url);
60
+ if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
61
+ throw new Error(`Fetch blocked: host "${parsed.hostname}" is not in the allowlist`);
62
+ }
63
+
55
64
  const controller = new AbortController();
56
65
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
57
66
 
@@ -46,6 +46,11 @@ const REQUIRED_METRICS = [
46
46
  "redundant_indexes",
47
47
  // Stats reset info (H002)
48
48
  "stats_reset",
49
+ // Bloat estimation (F004, F005)
50
+ "pg_table_bloat",
51
+ "pg_btree_bloat",
52
+ // I/O statistics (I001)
53
+ "pg_stat_io",
49
54
  ];
50
55
 
51
56
  function main() {
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Generates release notes from git commit history using conventional commits.
6
6
  * Analyzes commits between two references (tags, commits, or branches).
7
+ * Fetches MR numbers from GitLab API and includes links.
7
8
  *
8
9
  * Usage:
9
10
  * bun run scripts/generate-release-notes.ts [options]
@@ -14,11 +15,16 @@
14
15
  * --version <ver> Version string for the release (e.g., "0.14.0")
15
16
  * --format <fmt> Output format: markdown (default), json
16
17
  * --output <file> Write to file instead of stdout
18
+ * --gitlab-project GitLab project path (default: postgres-ai/postgresai)
17
19
  */
18
20
 
19
21
  import { execFileSync } from "child_process";
20
22
  import * as fs from "fs";
21
23
 
24
+ // GitLab project configuration
25
+ const GITLAB_PROJECT = "postgres-ai/postgresai";
26
+ const GITLAB_MR_URL = `https://gitlab.com/${GITLAB_PROJECT}/-/merge_requests`;
27
+
22
28
  // Valid git ref pattern: alphanumeric, dots, hyphens, underscores, slashes, tildes, carets
23
29
  const GIT_REF_PATTERN = /^[a-zA-Z0-9._~^/+-]+$/;
24
30
  // Valid git SHA pattern: 7-40 hex characters
@@ -65,6 +71,23 @@ function isExcludedAuthor(author: string): boolean {
65
71
  return EXCLUDED_AUTHORS.some((excluded) => author.toLowerCase().includes(excluded.toLowerCase()));
66
72
  }
67
73
 
74
+ interface GitLabMR {
75
+ iid: number;
76
+ title: string;
77
+ source_branch: string;
78
+ merged_at: string | null;
79
+ labels?: string[];
80
+ }
81
+
82
+ interface ChangelogEntry {
83
+ mrNumber: number;
84
+ title: string;
85
+ type: string;
86
+ scope: string | null;
87
+ breaking: boolean;
88
+ authors: Set<string>;
89
+ }
90
+
68
91
  interface ParsedCommit {
69
92
  hash: string;
70
93
  shortHash: string;
@@ -75,6 +98,7 @@ interface ParsedCommit {
75
98
  breaking: boolean;
76
99
  date: string;
77
100
  author: string;
101
+ mrNumber?: number;
78
102
  }
79
103
 
80
104
  interface ReleaseNotes {
@@ -83,8 +107,9 @@ interface ReleaseNotes {
83
107
  sinceRef: string;
84
108
  untilRef: string;
85
109
  commits: ParsedCommit[];
86
- categories: Record<string, ParsedCommit[]>;
87
- breaking: ParsedCommit[];
110
+ entries: ChangelogEntry[];
111
+ categories: Record<string, ChangelogEntry[]>;
112
+ breaking: ChangelogEntry[];
88
113
  stats: {
89
114
  total: number;
90
115
  features: number;
@@ -102,6 +127,55 @@ function gitExec(args: string[]): string {
102
127
  }
103
128
  }
104
129
 
130
+ // Cache for GitLab MRs
131
+ let mrCache: GitLabMR[] | null = null;
132
+
133
+ async function fetchMergedMRs(sinceDate: string): Promise<GitLabMR[]> {
134
+ if (mrCache) return mrCache;
135
+
136
+ const mrs: GitLabMR[] = [];
137
+ const projectEncoded = encodeURIComponent(GITLAB_PROJECT);
138
+
139
+ try {
140
+ // Fetch merged MRs, paginate through all results
141
+ let page = 1;
142
+ const perPage = 100;
143
+
144
+ while (true) {
145
+ const url = `https://gitlab.com/api/v4/projects/${projectEncoded}/merge_requests?state=merged&per_page=${perPage}&page=${page}&updated_after=${sinceDate}`;
146
+ const response = await fetch(url);
147
+
148
+ if (!response.ok) break;
149
+
150
+ const pageMrs: GitLabMR[] = await response.json();
151
+ if (pageMrs.length === 0) break;
152
+
153
+ mrs.push(...pageMrs);
154
+
155
+ if (pageMrs.length < perPage) break;
156
+ page++;
157
+ }
158
+ } catch (err) {
159
+ // Silently fail - MR links are optional
160
+ }
161
+
162
+ mrCache = mrs;
163
+ return mrs;
164
+ }
165
+
166
+ function buildBranchToMRMap(mrs: GitLabMR[]): Map<string, GitLabMR> {
167
+ const map = new Map<string, GitLabMR>();
168
+ for (const mr of mrs) {
169
+ map.set(mr.source_branch, mr);
170
+ }
171
+ return map;
172
+ }
173
+
174
+ function getMRNumberFromBranch(branch: string, mrMap: Map<string, GitLabMR>): number | undefined {
175
+ const mr = mrMap.get(branch);
176
+ return mr?.iid;
177
+ }
178
+
105
179
  function parseArgs(): { since: string; until: string; version: string; format: string; output: string | null } {
106
180
  const args = process.argv.slice(2);
107
181
  const result = { since: "", until: "HEAD", version: "", format: "markdown", output: null as string | null };
@@ -264,6 +338,127 @@ function categorizeCommits(commits: ParsedCommit[]): Record<string, ParsedCommit
264
338
  return categories;
265
339
  }
266
340
 
341
+ function parseMRTitle(title: string): { type: string; scope: string | null; subject: string; breaking: boolean } {
342
+ // Parse conventional commit format from MR title: type(scope): subject
343
+ const match = title.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
344
+
345
+ if (match) {
346
+ let type = match[1]?.toLowerCase() || "other";
347
+ if (type === "feature") type = "feat";
348
+ if (type === "bugfix") type = "fix";
349
+
350
+ return {
351
+ type,
352
+ scope: match[2] || null,
353
+ subject: match[4] || title,
354
+ breaking: !!match[3],
355
+ };
356
+ }
357
+
358
+ // If not conventional commit format, try to infer type from title
359
+ const lowerTitle = title.toLowerCase();
360
+ let inferredType = "other";
361
+ if (lowerTitle.startsWith("fix") || lowerTitle.includes("fix:") || lowerTitle.includes("bugfix")) {
362
+ inferredType = "fix";
363
+ } else if (lowerTitle.startsWith("feat") || lowerTitle.startsWith("add ") || lowerTitle.startsWith("implement")) {
364
+ inferredType = "feat";
365
+ } else if (lowerTitle.startsWith("docs") || lowerTitle.startsWith("doc:")) {
366
+ inferredType = "docs";
367
+ } else if (lowerTitle.startsWith("refactor")) {
368
+ inferredType = "refactor";
369
+ } else if (lowerTitle.startsWith("perf") || lowerTitle.includes("performance")) {
370
+ inferredType = "perf";
371
+ } else if (lowerTitle.startsWith("chore") || lowerTitle.startsWith("ci") || lowerTitle.startsWith("build")) {
372
+ inferredType = "chore";
373
+ } else if (lowerTitle.startsWith("test")) {
374
+ inferredType = "test";
375
+ }
376
+
377
+ return {
378
+ type: inferredType,
379
+ scope: null,
380
+ subject: title,
381
+ breaking: false,
382
+ };
383
+ }
384
+
385
+ interface GroupResult {
386
+ entries: Map<number, ChangelogEntry>;
387
+ orphanedCommits: ParsedCommit[];
388
+ }
389
+
390
+ function groupCommitsByMR(commits: ParsedCommit[], mrMap: Map<string, GitLabMR>): GroupResult {
391
+ const entries = new Map<number, ChangelogEntry>();
392
+ const orphanedCommits: ParsedCommit[] = [];
393
+
394
+ for (const commit of commits) {
395
+ if (!commit.mrNumber) {
396
+ orphanedCommits.push(commit);
397
+ continue;
398
+ }
399
+
400
+ if (entries.has(commit.mrNumber)) {
401
+ // Add author to existing entry
402
+ const entry = entries.get(commit.mrNumber)!;
403
+ if (commit.author && !isExcludedAuthor(commit.author)) {
404
+ entry.authors.add(normalizeAuthor(commit.author));
405
+ }
406
+ } else {
407
+ // Find MR data to get the title
408
+ let mrTitle = commit.subject;
409
+ for (const [, mr] of mrMap) {
410
+ if (mr.iid === commit.mrNumber) {
411
+ mrTitle = mr.title;
412
+ break;
413
+ }
414
+ }
415
+
416
+ const parsed = parseMRTitle(mrTitle);
417
+ entries.set(commit.mrNumber, {
418
+ mrNumber: commit.mrNumber,
419
+ title: parsed.subject,
420
+ type: parsed.type,
421
+ scope: parsed.scope,
422
+ breaking: parsed.breaking,
423
+ authors: new Set(commit.author && !isExcludedAuthor(commit.author) ? [normalizeAuthor(commit.author)] : []),
424
+ });
425
+ }
426
+ }
427
+
428
+ return { entries, orphanedCommits };
429
+ }
430
+
431
+ function categorizeEntries(entries: Map<number, ChangelogEntry>): Record<string, ChangelogEntry[]> {
432
+ const categories: Record<string, ChangelogEntry[]> = {};
433
+
434
+ for (const [, entry] of entries) {
435
+ const type = COMMIT_TYPES[entry.type] ? entry.type : "other";
436
+ if (!categories[type]) {
437
+ categories[type] = [];
438
+ }
439
+ categories[type].push(entry);
440
+ }
441
+
442
+ return categories;
443
+ }
444
+
445
+ function formatMRLink(mrNumber: number | undefined): string {
446
+ if (!mrNumber) return "";
447
+ // Use HTML link so copy-pasting to GitHub shows "!XXX" not the full URL
448
+ return ` (<a href="${GITLAB_MR_URL}/${mrNumber}">!${mrNumber}</a>)`;
449
+ }
450
+
451
+ function formatTitle(title: string): string {
452
+ // Wrap CLI flags (--something) in backticks
453
+ let formatted = title.replace(/\s(--[\w-]+)/g, " `$1`");
454
+ // Wrap CLI commands like "postgresai init", "postgresai checkup" in backticks
455
+ // Only match known subcommands, not arbitrary words
456
+ formatted = formatted.replace(/\b(postgresai\s+(?:init|checkup|mon|auth|prepare-db|unprepare-db))\b/g, "`$1`");
457
+ // Wrap PostgreSQL technical terms
458
+ formatted = formatted.replace(/\b(pg_stat_statements|pg_statistic|pg_catalog)\b/g, "`$1`");
459
+ return formatted;
460
+ }
461
+
267
462
  function generateMarkdown(notes: ReleaseNotes): string {
268
463
  const lines: string[] = [];
269
464
 
@@ -284,11 +479,12 @@ function generateMarkdown(notes: ReleaseNotes): string {
284
479
 
285
480
  // Breaking changes (if any)
286
481
  if (notes.breaking.length > 0) {
287
- lines.push("## Breaking Changes");
482
+ lines.push("## ⚠️ Breaking Changes");
288
483
  lines.push("");
289
- for (const commit of notes.breaking) {
290
- const scopeStr = commit.scope ? `**${commit.scope}:** ` : "";
291
- lines.push(`- ${scopeStr}${commit.subject}`);
484
+ for (const entry of notes.breaking) {
485
+ const scopeStr = entry.scope ? `**${entry.scope}:** ` : "";
486
+ const mrLink = formatMRLink(entry.mrNumber);
487
+ lines.push(`- ${scopeStr}${entry.title}${mrLink}`);
292
488
  }
293
489
  lines.push("");
294
490
  }
@@ -301,43 +497,28 @@ function generateMarkdown(notes: ReleaseNotes): string {
301
497
  });
302
498
 
303
499
  for (const type of sortedTypes) {
304
- const commits = notes.categories[type];
305
- if (!commits || commits.length === 0) continue;
500
+ const entries = notes.categories[type];
501
+ if (!entries || entries.length === 0) continue;
306
502
 
307
503
  const typeInfo = COMMIT_TYPES[type] || { title: "Other Changes", emoji: "📝", priority: 99 };
308
504
  lines.push(`## ${typeInfo.emoji} ${typeInfo.title}`);
309
505
  lines.push("");
310
506
 
311
- // Group by scope within each type
312
- const byScope: Record<string, ParsedCommit[]> = {};
313
- for (const commit of commits) {
314
- const scope = commit.scope || "_general";
315
- if (!byScope[scope]) byScope[scope] = [];
316
- byScope[scope].push(commit);
317
- }
318
-
319
- // Sort scopes: known scopes first, then alphabetically
320
- const scopes = Object.keys(byScope).sort((a, b) => {
321
- if (a === "_general") return 1;
322
- if (b === "_general") return -1;
323
- const aKnown = KNOWN_SCOPES.includes(a);
324
- const bKnown = KNOWN_SCOPES.includes(b);
325
- if (aKnown && !bKnown) return -1;
326
- if (!aKnown && bKnown) return 1;
327
- return a.localeCompare(b);
328
- });
329
-
330
- for (const scope of scopes) {
331
- const scopeCommits = byScope[scope] || [];
332
- if (scope !== "_general" && scopeCommits.length > 0) {
333
- lines.push(`### ${scope}`);
334
- lines.push("");
335
- }
336
- for (const commit of scopeCommits) {
337
- lines.push(`- ${commit.subject} (\`${commit.shortHash}\`)`);
507
+ // Flat list with scope prefix (no sub-headers)
508
+ for (const entry of entries) {
509
+ const mrLink = formatMRLink(entry.mrNumber);
510
+ // Format scope: uppercase CLI, MCP; title case others
511
+ let scopeStr = "";
512
+ if (entry.scope) {
513
+ const upperScopes = ["cli", "mcp", "api", "sql", "ci"];
514
+ const formattedScope = upperScopes.includes(entry.scope.toLowerCase())
515
+ ? entry.scope.toUpperCase()
516
+ : entry.scope;
517
+ scopeStr = `**${formattedScope}:** `;
338
518
  }
339
- lines.push("");
519
+ lines.push(`- ${scopeStr}${formatTitle(entry.title)}${mrLink}`);
340
520
  }
521
+ lines.push("");
341
522
  }
342
523
 
343
524
  // Contributors
@@ -369,30 +550,83 @@ async function main() {
369
550
  const log = (msg: string) => process.stderr.write(msg + "\n");
370
551
  log(`Analyzing commits from ${since} to ${until}...`);
371
552
 
553
+ // Get the date of the since ref for MR filtering
554
+ const sinceDate = gitExec(["log", "-1", "--format=%aI", since]) || "2024-01-01T00:00:00Z";
555
+
556
+ // Fetch MRs from GitLab API
557
+ log("Fetching MR data from GitLab...");
558
+ const mrs = await fetchMergedMRs(sinceDate);
559
+ const mrBranchMap = buildBranchToMRMap(mrs);
560
+ log(`Found ${mrs.length} merged MRs`);
561
+
372
562
  // Get and parse commits
373
563
  const hashes = getCommitsBetween(since, until);
374
564
  log(`Found ${hashes.length} commits to analyze`);
375
565
 
566
+ // Build a map of commit hash to branch name from merge commits
567
+ const commitToBranch = new Map<string, string>();
568
+ const mergeLog = gitExec(["log", `${since}..${until}`, "--merges", "--format=%H|%P|%s"]);
569
+ for (const line of mergeLog.split("\n").filter(Boolean)) {
570
+ const [mergeHash, parents, subject] = line.split("|");
571
+ const branchMatch = subject?.match(/Merge branch '([^']+)'/);
572
+ if (branchMatch && parents) {
573
+ const branch = branchMatch[1];
574
+ // The second parent is the merged branch
575
+ const mergedParent = parents.split(" ")[1];
576
+ if (mergedParent && branch) {
577
+ // Get all commits that are ancestors of mergedParent but not of first parent
578
+ const firstParent = parents.split(" ")[0];
579
+ if (firstParent) {
580
+ const branchCommits = gitExec(["log", "--format=%H", `${firstParent}..${mergedParent}`]);
581
+ for (const hash of branchCommits.split("\n").filter(Boolean)) {
582
+ commitToBranch.set(hash, branch);
583
+ }
584
+ }
585
+ }
586
+ }
587
+ }
588
+
376
589
  const commits: ParsedCommit[] = [];
377
590
  for (const hash of hashes) {
378
591
  const parsed = parseCommit(hash);
379
592
  if (parsed) {
593
+ // Try to find MR number from branch name
594
+ const branch = commitToBranch.get(hash);
595
+ if (branch) {
596
+ parsed.mrNumber = getMRNumberFromBranch(branch, mrBranchMap);
597
+ }
380
598
  commits.push(parsed);
381
599
  }
382
600
  }
383
601
 
384
- // Build release notes structure
385
- const categories = categorizeCommits(commits);
386
- const breaking = commits.filter((c) => c.breaking);
602
+ // Group commits by MR and build changelog entries
603
+ const { entries: entriesMap, orphanedCommits } = groupCommitsByMR(commits, mrBranchMap);
604
+ const entries = Array.from(entriesMap.values());
605
+ log(`Grouped into ${entries.length} changelog entries`);
606
+
607
+ // Warn about orphaned commits (no MR association found)
608
+ if (orphanedCommits.length > 0) {
609
+ log(`Warning: ${orphanedCommits.length} commits have no MR association:`);
610
+ for (const commit of orphanedCommits.slice(0, 10)) {
611
+ log(` - ${commit.shortHash}: ${commit.subject}`);
612
+ }
613
+ if (orphanedCommits.length > 10) {
614
+ log(` ... and ${orphanedCommits.length - 10} more`);
615
+ }
616
+ }
617
+
618
+ // Categorize entries by type
619
+ const categories = categorizeEntries(entriesMap as Map<number, ChangelogEntry>);
620
+ const breaking = entries.filter((e) => e.breaking);
387
621
 
388
- // Normalize author names and exclude bots/AI
389
- const contributors = [
390
- ...new Set(
391
- commits
392
- .map((c) => normalizeAuthor(c.author))
393
- .filter((author) => author && !isExcludedAuthor(author))
394
- ),
395
- ];
622
+ // Collect all contributors from entries
623
+ const contributorSet = new Set<string>();
624
+ for (const entry of entries) {
625
+ for (const author of entry.authors) {
626
+ contributorSet.add(author);
627
+ }
628
+ }
629
+ const contributors = Array.from(contributorSet);
396
630
 
397
631
  const notes: ReleaseNotes = {
398
632
  version: args.version || "",
@@ -400,10 +634,11 @@ async function main() {
400
634
  sinceRef: since,
401
635
  untilRef: until,
402
636
  commits,
637
+ entries,
403
638
  categories,
404
639
  breaking,
405
640
  stats: {
406
- total: commits.length,
641
+ total: entries.length,
407
642
  features: categories["feat"]?.length || 0,
408
643
  fixes: categories["fix"]?.length || 0,
409
644
  contributors,
@@ -0,0 +1,139 @@
1
+ # Permission Check Test Summary
2
+
3
+ ## Changes Made
4
+
5
+ Changed all references from `public.pg_statistic` to `postgres_ai.pg_statistic` in:
6
+ - `cli/lib/init.ts` - Permission check SQL query
7
+ - `cli/test/init.test.ts` - All test expectations (28 occurrences)
8
+
9
+ ## Key Fix: Safe Schema Checking
10
+
11
+ **Before (883fa95):**
12
+ ```sql
13
+ exists (
14
+ select from pg_views
15
+ where schemaname = 'public' and viewname = 'pg_statistic'
16
+ ) as granted
17
+ ```
18
+
19
+ **After (6db79f6) - INCORRECT, caused crashes:**
20
+ ```sql
21
+ to_regclass('postgres_ai.pg_statistic') is not null as granted
22
+ ```
23
+
24
+ **Current (this fix):**
25
+ ```sql
26
+ case
27
+ when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
28
+ else to_regclass('postgres_ai.pg_statistic') is not null
29
+ end as granted
30
+ ```
31
+
32
+ ### Why this fix matters
33
+
34
+ **Issue with bare `to_regclass()`:**
35
+ - Returns NULL when the schema doesn't exist ✓
36
+ - Returns NULL when the view doesn't exist ✓
37
+ - **Throws error** when the schema exists but user lacks USAGE privilege ✗
38
+
39
+ **Fix:**
40
+ - Check `has_schema_privilege()` first to avoid the permission error
41
+ - Returns NULL safely in all cases where we can't check the view
42
+ - Prevents crashes when postgres_ai schema exists but user lacks USAGE
43
+
44
+ ## Test Results
45
+
46
+ ### Unit Tests ✅
47
+ ```
48
+ ✓ 95 tests passed across 3 files
49
+ - 84 tests in init.test.ts (including 9 checkCurrentUserPermissions tests)
50
+ - 2 tests in config-consistency.test.ts
51
+ - 9 tests in permission-check-sql.test.ts
52
+ ```
53
+
54
+ ### Expected Behavior by Scenario
55
+
56
+ | Scenario | User Permissions | postgres_ai Schema | Expected Result |
57
+ |----------|-----------------|-------------------|-----------------|
58
+ | 1. Superuser | superuser + postgres_ai.pg_statistic | ✓ Exists | ✅ PASS (clean) |
59
+ | 2. pg_monitor, no schema access | pg_monitor only | ✗ No USAGE | ✅ PASS (warning) |
60
+ | 3. No pg_monitor | minimal permissions | ✗ Doesn't exist | ✅ PASS (error + fix SQL) |
61
+ | 8. After prepare-db | pg_monitor + postgres_ai grants | ✓ Exists + SELECT | ✅ PASS (clean) |
62
+
63
+ ### SQL Behavior Verification
64
+
65
+ **Scenario 2 & 3: Schema doesn't exist or no USAGE**
66
+ ```sql
67
+ -- Check privilege first, then to_regclass (no crash)
68
+ case
69
+ when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
70
+ else to_regclass('postgres_ai.pg_statistic') is not null
71
+ end → NULL
72
+
73
+ -- SELECT check is skipped (returns NULL, not treated as missing optional)
74
+ case
75
+ when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
76
+ when to_regclass('postgres_ai.pg_statistic') is null then null
77
+ else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
78
+ end → NULL
79
+ ```
80
+
81
+ **Scenario 1 & 8: Schema exists with proper grants**
82
+ ```sql
83
+ -- User has USAGE, to_regclass returns OID (view is visible)
84
+ case
85
+ when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
86
+ else to_regclass('postgres_ai.pg_statistic') is not null
87
+ end → TRUE
88
+
89
+ -- SELECT check is performed
90
+ has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select') → TRUE/FALSE
91
+ ```
92
+
93
+ ## Integration Test Limitations
94
+
95
+ Integration tests cannot run due to locale configuration issues with `initdb`:
96
+ ```
97
+ error: initdb: error: invalid locale settings; check LANG and LC_* environment variables
98
+ ```
99
+
100
+ However, unit tests provide comprehensive coverage of the permission check logic, including:
101
+ - All permission scenarios (granted, denied, skipped)
102
+ - Multiple missing permissions
103
+ - Error propagation
104
+ - Fix command generation
105
+ - Message formatting
106
+
107
+ ## Schema Consistency
108
+
109
+ The change ensures consistency across the codebase:
110
+ - ✅ `cli/lib/init.ts` - now checks postgres_ai.pg_statistic
111
+ - ✅ `cli/lib/supabase.ts` - already checks postgres_ai.pg_statistic
112
+ - ✅ `cli/sql/03.permissions.sql` - creates postgres_ai.pg_statistic
113
+ - ✅ `config/target-db/init.sql` - creates postgres_ai.pg_statistic
114
+ - ✅ `config/pgwatch-prometheus/metrics.yml` - references postgres_ai.pg_statistic
115
+
116
+ ## Commits
117
+
118
+ 1. **955cff2** - `fix: change public.pg_statistic to postgres_ai.pg_statistic`
119
+ - Updated permission check queries
120
+ - Updated all test expectations
121
+
122
+ 2. **6db79f6** - `fix: use to_regclass() for safe postgres_ai.pg_statistic check`
123
+ - Replaced pg_views query with to_regclass()
124
+ - ⚠️ This introduced a bug: crashes when schema exists but user lacks USAGE
125
+
126
+ 3. **[current]** - `fix: wrap to_regclass() with has_schema_privilege() check`
127
+ - Fixed crash when postgres_ai schema exists but user lacks USAGE privilege
128
+ - Added privilege check before calling to_regclass() in all locations
129
+ - Updated in: init.ts (3 places) and supabase.ts (1 place)
130
+
131
+ ## Verification Command
132
+
133
+ ```bash
134
+ # Run all permission-related tests
135
+ bun test test/init.test.ts test/config-consistency.test.ts test/permission-check-sql.test.ts
136
+
137
+ # Verify no public.pg_statistic references remain (except in comments)
138
+ git grep -n 'public\.pg_statistic' cli/
139
+ ```