postgresai 0.14.0-dev.85 → 0.14.0-dev.86

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.
@@ -13064,7 +13064,7 @@ var {
13064
13064
  // package.json
13065
13065
  var package_default = {
13066
13066
  name: "postgresai",
13067
- version: "0.14.0-dev.85",
13067
+ version: "0.14.0-dev.86",
13068
13068
  description: "postgres_ai CLI",
13069
13069
  license: "Apache-2.0",
13070
13070
  private: false,
@@ -15889,7 +15889,7 @@ var Result = import_lib.default.Result;
15889
15889
  var TypeOverrides = import_lib.default.TypeOverrides;
15890
15890
  var defaults = import_lib.default.defaults;
15891
15891
  // package.json
15892
- var version = "0.14.0-dev.85";
15892
+ var version = "0.14.0-dev.86";
15893
15893
  var package_default2 = {
15894
15894
  name: "postgresai",
15895
15895
  version,
@@ -24971,14 +24971,7 @@ end $$;`;
24971
24971
  });
24972
24972
  let permissionsSql = applyTemplate(loadSqlTemplate("03.permissions.sql"), vars);
24973
24973
  if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
24974
- permissionsSql = permissionsSql.split(`
24975
- `).filter((line) => {
24976
- const trimmed = line.trim();
24977
- if (trimmed.startsWith("--") || trimmed === "")
24978
- return true;
24979
- return !/^\s*alter\s+user\s+/i.test(line);
24980
- }).join(`
24981
- `);
24974
+ permissionsSql = permissionsSql.replace(/-- \[SEARCH_PATH_BLOCK_START\][\s\S]*?-- \[SEARCH_PATH_BLOCK_END\]\n?/, "");
24982
24975
  }
24983
24976
  steps.push({
24984
24977
  name: "03.permissions",
@@ -25162,6 +25155,19 @@ async function verifyInitSetup(params) {
25162
25155
  if (!schemaUsageRes.rows?.[0]?.ok) {
25163
25156
  missingRequired.push("USAGE on schema public");
25164
25157
  }
25158
+ const extSchemaRes = await params.client.query(`
25159
+ select n.nspname as schema
25160
+ from pg_extension e
25161
+ join pg_namespace n on e.extnamespace = n.oid
25162
+ where e.extname = 'pg_stat_statements'
25163
+ `);
25164
+ const extSchema = extSchemaRes.rows?.[0]?.schema;
25165
+ if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
25166
+ const extSchemaUsageRes = await params.client.query("select has_schema_privilege($1, $2, 'USAGE') as ok", [role, extSchema]);
25167
+ if (!extSchemaUsageRes.rows?.[0]?.ok) {
25168
+ missingRequired.push(`USAGE on schema ${extSchema} (pg_stat_statements location)`);
25169
+ }
25170
+ }
25165
25171
  if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
25166
25172
  const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
25167
25173
  const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
@@ -25173,6 +25179,11 @@ async function verifyInitSetup(params) {
25173
25179
  if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
25174
25180
  missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
25175
25181
  }
25182
+ if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
25183
+ if (!sp.includes(extSchema.toLowerCase())) {
25184
+ missingRequired.push(`role search_path includes ${extSchema} (pg_stat_statements location)`);
25185
+ }
25186
+ }
25176
25187
  }
25177
25188
  }
25178
25189
  const explainFnRes = await params.client.query("select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok", [role]);
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
32
32
  -- Hardened clusters sometimes revoke PUBLIC on schema public
33
33
  grant usage on schema public to {{ROLE_IDENT}};
34
34
 
35
- -- Keep search_path predictable; postgres_ai first so our objects are found
36
- alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
35
+ -- Grant access to the schema where pg_stat_statements is installed.
36
+ -- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
37
+ -- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
38
+ do $$
39
+ declare
40
+ ext_schema text;
41
+ begin
42
+ select n.nspname into ext_schema
43
+ from pg_extension e
44
+ join pg_namespace n on e.extnamespace = n.oid
45
+ where e.extname = 'pg_stat_statements';
46
+
47
+ -- Only grant if extension exists and is in a non-standard schema
48
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
49
+ execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
50
+ end if;
51
+ end $$;
52
+
53
+ -- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
54
+ -- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
55
+ do $$
56
+ declare
57
+ ext_schema text;
58
+ sp text;
59
+ begin
60
+ -- Detect pg_stat_statements extension schema
61
+ select n.nspname into ext_schema
62
+ from pg_extension e
63
+ join pg_namespace n on e.extnamespace = n.oid
64
+ where e.extname = 'pg_stat_statements';
65
+
66
+ -- Build search_path: include extension schema if in non-standard location
67
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
68
+ sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
69
+ else
70
+ sp := 'postgres_ai, "$user", public, pg_catalog';
71
+ end if;
72
+
73
+ execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
74
+ end $$;
75
+ -- [SEARCH_PATH_BLOCK_END]
37
76
 
38
77
 
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
32
32
  -- Hardened clusters sometimes revoke PUBLIC on schema public
33
33
  grant usage on schema public to {{ROLE_IDENT}};
34
34
 
35
- -- Keep search_path predictable; postgres_ai first so our objects are found
36
- alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
35
+ -- Grant access to the schema where pg_stat_statements is installed.
36
+ -- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
37
+ -- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
38
+ do $$
39
+ declare
40
+ ext_schema text;
41
+ begin
42
+ select n.nspname into ext_schema
43
+ from pg_extension e
44
+ join pg_namespace n on e.extnamespace = n.oid
45
+ where e.extname = 'pg_stat_statements';
46
+
47
+ -- Only grant if extension exists and is in a non-standard schema
48
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
49
+ execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
50
+ end if;
51
+ end $$;
52
+
53
+ -- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
54
+ -- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
55
+ do $$
56
+ declare
57
+ ext_schema text;
58
+ sp text;
59
+ begin
60
+ -- Detect pg_stat_statements extension schema
61
+ select n.nspname into ext_schema
62
+ from pg_extension e
63
+ join pg_namespace n on e.extnamespace = n.oid
64
+ where e.extname = 'pg_stat_statements';
65
+
66
+ -- Build search_path: include extension schema if in non-standard location
67
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
68
+ sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
69
+ else
70
+ sp := 'postgres_ai, "$user", public, pg_catalog';
71
+ end if;
72
+
73
+ execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
74
+ end $$;
75
+ -- [SEARCH_PATH_BLOCK_END]
37
76
 
38
77
 
package/lib/init.ts CHANGED
@@ -538,16 +538,12 @@ end $$;`;
538
538
  // Some providers restrict ALTER USER - remove those statements.
539
539
  // TODO: Make this more flexible by allowing users to specify which statements to skip via config.
540
540
  if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
541
- permissionsSql = permissionsSql
542
- .split("\n")
543
- .filter((line) => {
544
- const trimmed = line.trim();
545
- // Keep comments and empty lines
546
- if (trimmed.startsWith("--") || trimmed === "") return true;
547
- // Filter out ALTER USER statements (case-insensitive, flexible whitespace)
548
- return !/^\s*alter\s+user\s+/i.test(line);
549
- })
550
- .join("\n");
541
+ // Remove the entire search_path DO block (marked with SEARCH_PATH_BLOCK_START/END)
542
+ // since it contains ALTER USER and can't be line-filtered without breaking the DO block.
543
+ permissionsSql = permissionsSql.replace(
544
+ /-- \[SEARCH_PATH_BLOCK_START\][\s\S]*?-- \[SEARCH_PATH_BLOCK_END\]\n?/,
545
+ ""
546
+ );
551
547
  }
552
548
 
553
549
  steps.push({
@@ -838,6 +834,24 @@ export async function verifyInitSetup(params: {
838
834
  missingRequired.push("USAGE on schema public");
839
835
  }
840
836
 
837
+ // Check access to pg_stat_statements extension schema (may be 'extensions' on Supabase)
838
+ const extSchemaRes = await params.client.query(`
839
+ select n.nspname as schema
840
+ from pg_extension e
841
+ join pg_namespace n on e.extnamespace = n.oid
842
+ where e.extname = 'pg_stat_statements'
843
+ `);
844
+ const extSchema = extSchemaRes.rows?.[0]?.schema;
845
+ if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
846
+ const extSchemaUsageRes = await params.client.query(
847
+ "select has_schema_privilege($1, $2, 'USAGE') as ok",
848
+ [role, extSchema]
849
+ );
850
+ if (!extSchemaUsageRes.rows?.[0]?.ok) {
851
+ missingRequired.push(`USAGE on schema ${extSchema} (pg_stat_statements location)`);
852
+ }
853
+ }
854
+
841
855
  // Some providers don't allow setting search_path via ALTER USER - skip this check.
842
856
  // TODO: Make this more flexible by allowing users to specify which checks to skip via config.
843
857
  if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
@@ -848,10 +862,17 @@ export async function verifyInitSetup(params: {
848
862
  missingRequired.push("role search_path is set");
849
863
  } else {
850
864
  // We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
865
+ // Also verify search_path includes the pg_stat_statements schema if in a non-standard location.
851
866
  const sp = spLine.toLowerCase();
852
867
  if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
853
868
  missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
854
869
  }
870
+ // If pg_stat_statements is in a non-standard schema (e.g., 'extensions' on Supabase), verify it's in search_path
871
+ if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
872
+ if (!sp.includes(extSchema.toLowerCase())) {
873
+ missingRequired.push(`role search_path includes ${extSchema} (pg_stat_statements location)`);
874
+ }
875
+ }
855
876
  }
856
877
  }
857
878
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-dev.85",
3
+ "version": "0.14.0-dev.86",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
32
32
  -- Hardened clusters sometimes revoke PUBLIC on schema public
33
33
  grant usage on schema public to {{ROLE_IDENT}};
34
34
 
35
- -- Keep search_path predictable; postgres_ai first so our objects are found
36
- alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
35
+ -- Grant access to the schema where pg_stat_statements is installed.
36
+ -- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
37
+ -- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
38
+ do $$
39
+ declare
40
+ ext_schema text;
41
+ begin
42
+ select n.nspname into ext_schema
43
+ from pg_extension e
44
+ join pg_namespace n on e.extnamespace = n.oid
45
+ where e.extname = 'pg_stat_statements';
46
+
47
+ -- Only grant if extension exists and is in a non-standard schema
48
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
49
+ execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
50
+ end if;
51
+ end $$;
52
+
53
+ -- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
54
+ -- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
55
+ do $$
56
+ declare
57
+ ext_schema text;
58
+ sp text;
59
+ begin
60
+ -- Detect pg_stat_statements extension schema
61
+ select n.nspname into ext_schema
62
+ from pg_extension e
63
+ join pg_namespace n on e.extnamespace = n.oid
64
+ where e.extname = 'pg_stat_statements';
65
+
66
+ -- Build search_path: include extension schema if in non-standard location
67
+ if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
68
+ sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
69
+ else
70
+ sp := 'postgres_ai, "$user", public, pg_catalog';
71
+ end if;
72
+
73
+ execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
74
+ end $$;
75
+ -- [SEARCH_PATH_BLOCK_END]
37
76
 
38
77
 
package/test/init.test.ts CHANGED
@@ -281,7 +281,7 @@ describe("init module", () => {
281
281
  return { rowCount: 1, rows: [] };
282
282
  }
283
283
  if (String(sql).includes("select rolconfig")) {
284
- return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
284
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
285
285
  }
286
286
  if (String(sql).includes("from pg_catalog.pg_roles")) {
287
287
  return { rowCount: 1, rows: [] };
@@ -307,6 +307,10 @@ describe("init module", () => {
307
307
  if (String(sql).includes("has_schema_privilege")) {
308
308
  return { rowCount: 1, rows: [{ ok: true }] };
309
309
  }
310
+ // Query for pg_stat_statements extension schema location
311
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
312
+ return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
313
+ }
310
314
 
311
315
  throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
312
316
  },
@@ -363,6 +367,10 @@ describe("init module", () => {
363
367
  if (String(sql).includes("has_schema_privilege")) {
364
368
  return { rowCount: 1, rows: [{ ok: true }] };
365
369
  }
370
+ // Query for pg_stat_statements extension schema location (Supabase uses 'extensions' schema)
371
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
372
+ return { rowCount: 1, rows: [{ schema: "extensions" }] };
373
+ }
366
374
 
367
375
  throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
368
376
  },
@@ -382,6 +390,335 @@ describe("init module", () => {
382
390
  expect(calls.some((c) => c.includes("select rolconfig"))).toBe(false);
383
391
  });
384
392
 
393
+ test("verifyInitSetup checks extensions schema when pg_stat_statements is there", async () => {
394
+ const calls: string[] = [];
395
+ const client = {
396
+ query: async (sql: string, params?: any) => {
397
+ calls.push(String(sql));
398
+
399
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
400
+ return { rowCount: 1, rows: [] };
401
+ }
402
+ if (String(sql).toLowerCase() === "rollback;") {
403
+ return { rowCount: 1, rows: [] };
404
+ }
405
+ if (String(sql).includes("select rolconfig")) {
406
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
407
+ }
408
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
409
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
410
+ }
411
+ if (String(sql).includes("has_database_privilege")) {
412
+ return { rowCount: 1, rows: [{ ok: true }] };
413
+ }
414
+ if (String(sql).includes("pg_has_role")) {
415
+ return { rowCount: 1, rows: [{ ok: true }] };
416
+ }
417
+ if (String(sql).includes("has_table_privilege")) {
418
+ return { rowCount: 1, rows: [{ ok: true }] };
419
+ }
420
+ if (String(sql).includes("to_regclass")) {
421
+ return { rowCount: 1, rows: [{ ok: true }] };
422
+ }
423
+ if (String(sql).includes("has_function_privilege")) {
424
+ return { rowCount: 1, rows: [{ ok: true }] };
425
+ }
426
+ // pg_stat_statements is in 'extensions' schema
427
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
428
+ return { rowCount: 1, rows: [{ schema: "extensions" }] };
429
+ }
430
+ // Check for USAGE on extensions schema
431
+ if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
432
+ return { rowCount: 1, rows: [{ ok: true }] };
433
+ }
434
+ if (String(sql).includes("has_schema_privilege")) {
435
+ return { rowCount: 1, rows: [{ ok: true }] };
436
+ }
437
+
438
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
439
+ },
440
+ };
441
+
442
+ const r = await init.verifyInitSetup({
443
+ client: client as any,
444
+ database: "mydb",
445
+ monitoringUser: DEFAULT_MONITORING_USER,
446
+ includeOptionalPermissions: false,
447
+ });
448
+ expect(r.ok).toBe(true);
449
+ expect(r.missingRequired.length).toBe(0);
450
+ // Should have queried for pg_stat_statements schema location
451
+ expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
452
+ });
453
+
454
+ test("verifyInitSetup reports missing extensions schema access", async () => {
455
+ const client = {
456
+ query: async (sql: string, params?: any) => {
457
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
458
+ return { rowCount: 1, rows: [] };
459
+ }
460
+ if (String(sql).toLowerCase() === "rollback;") {
461
+ return { rowCount: 1, rows: [] };
462
+ }
463
+ if (String(sql).includes("select rolconfig")) {
464
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
465
+ }
466
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
467
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
468
+ }
469
+ if (String(sql).includes("has_database_privilege")) {
470
+ return { rowCount: 1, rows: [{ ok: true }] };
471
+ }
472
+ if (String(sql).includes("pg_has_role")) {
473
+ return { rowCount: 1, rows: [{ ok: true }] };
474
+ }
475
+ if (String(sql).includes("has_table_privilege")) {
476
+ return { rowCount: 1, rows: [{ ok: true }] };
477
+ }
478
+ if (String(sql).includes("to_regclass")) {
479
+ return { rowCount: 1, rows: [{ ok: true }] };
480
+ }
481
+ if (String(sql).includes("has_function_privilege")) {
482
+ return { rowCount: 1, rows: [{ ok: true }] };
483
+ }
484
+ // pg_stat_statements is in 'extensions' schema
485
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
486
+ return { rowCount: 1, rows: [{ schema: "extensions" }] };
487
+ }
488
+ // No USAGE on extensions schema
489
+ if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
490
+ return { rowCount: 1, rows: [{ ok: false }] };
491
+ }
492
+ if (String(sql).includes("has_schema_privilege")) {
493
+ return { rowCount: 1, rows: [{ ok: true }] };
494
+ }
495
+
496
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
497
+ },
498
+ };
499
+
500
+ const r = await init.verifyInitSetup({
501
+ client: client as any,
502
+ database: "mydb",
503
+ monitoringUser: DEFAULT_MONITORING_USER,
504
+ includeOptionalPermissions: false,
505
+ });
506
+ expect(r.ok).toBe(false);
507
+ // Should report missing USAGE on extensions schema
508
+ expect(r.missingRequired.some((m) => m.includes("extensions") && m.includes("pg_stat_statements"))).toBe(true);
509
+ // Should also report missing extensions in search_path
510
+ expect(r.missingRequired.some((m) => m.includes("search_path") && m.includes("extensions"))).toBe(true);
511
+ });
512
+
513
+ test("buildInitPlan includes dynamic search_path with extension schema detection", async () => {
514
+ const plan = await init.buildInitPlan({
515
+ database: "mydb",
516
+ monitoringUser: DEFAULT_MONITORING_USER,
517
+ monitoringPassword: "pw",
518
+ includeOptionalPermissions: false,
519
+ });
520
+
521
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
522
+ expect(permStep).toBeTruthy();
523
+ // Should use dynamic DO block to set search_path based on detected extension schema
524
+ expect(permStep!.sql).toMatch(/alter\s+user.*set\s+search_path\s*=/i);
525
+ // Should detect pg_stat_statements extension schema dynamically
526
+ expect(permStep!.sql).toMatch(/quote_ident\(ext_schema\)/i);
527
+ });
528
+
529
+ test("buildInitPlan includes dynamic extension schema grant", async () => {
530
+ const plan = await init.buildInitPlan({
531
+ database: "mydb",
532
+ monitoringUser: DEFAULT_MONITORING_USER,
533
+ monitoringPassword: "pw",
534
+ includeOptionalPermissions: false,
535
+ });
536
+
537
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
538
+ expect(permStep).toBeTruthy();
539
+ // Should include DO block that grants USAGE on extension schema
540
+ expect(permStep!.sql).toMatch(/do\s+\$\$/i);
541
+ expect(permStep!.sql).toMatch(/pg_stat_statements/);
542
+ expect(permStep!.sql).toMatch(/grant usage on schema/i);
543
+ });
544
+
545
+ test("verifyInitSetup handles pg_stat_statements not installed", async () => {
546
+ const calls: string[] = [];
547
+ const client = {
548
+ query: async (sql: string, params?: any) => {
549
+ calls.push(String(sql));
550
+
551
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
552
+ return { rowCount: 1, rows: [] };
553
+ }
554
+ if (String(sql).toLowerCase() === "rollback;") {
555
+ return { rowCount: 1, rows: [] };
556
+ }
557
+ if (String(sql).includes("select rolconfig")) {
558
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
559
+ }
560
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
561
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
562
+ }
563
+ if (String(sql).includes("has_database_privilege")) {
564
+ return { rowCount: 1, rows: [{ ok: true }] };
565
+ }
566
+ if (String(sql).includes("pg_has_role")) {
567
+ return { rowCount: 1, rows: [{ ok: true }] };
568
+ }
569
+ if (String(sql).includes("has_table_privilege")) {
570
+ return { rowCount: 1, rows: [{ ok: true }] };
571
+ }
572
+ if (String(sql).includes("to_regclass")) {
573
+ return { rowCount: 1, rows: [{ ok: true }] };
574
+ }
575
+ if (String(sql).includes("has_function_privilege")) {
576
+ return { rowCount: 1, rows: [{ ok: true }] };
577
+ }
578
+ // pg_stat_statements is NOT installed - empty result
579
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
580
+ return { rowCount: 0, rows: [] };
581
+ }
582
+ if (String(sql).includes("has_schema_privilege")) {
583
+ return { rowCount: 1, rows: [{ ok: true }] };
584
+ }
585
+
586
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
587
+ },
588
+ };
589
+
590
+ const r = await init.verifyInitSetup({
591
+ client: client as any,
592
+ database: "mydb",
593
+ monitoringUser: DEFAULT_MONITORING_USER,
594
+ includeOptionalPermissions: false,
595
+ });
596
+ // Should pass without errors - missing extension shouldn't cause failure
597
+ expect(r.ok).toBe(true);
598
+ expect(r.missingRequired.length).toBe(0);
599
+ // Should have queried for pg_stat_statements schema location
600
+ expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
601
+ });
602
+
603
+ test("verifyInitSetup skips extension schema check when in pg_catalog", async () => {
604
+ const calls: string[] = [];
605
+ const client = {
606
+ query: async (sql: string, params?: any) => {
607
+ calls.push(String(sql));
608
+
609
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
610
+ return { rowCount: 1, rows: [] };
611
+ }
612
+ if (String(sql).toLowerCase() === "rollback;") {
613
+ return { rowCount: 1, rows: [] };
614
+ }
615
+ if (String(sql).includes("select rolconfig")) {
616
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
617
+ }
618
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
619
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
620
+ }
621
+ if (String(sql).includes("has_database_privilege")) {
622
+ return { rowCount: 1, rows: [{ ok: true }] };
623
+ }
624
+ if (String(sql).includes("pg_has_role")) {
625
+ return { rowCount: 1, rows: [{ ok: true }] };
626
+ }
627
+ if (String(sql).includes("has_table_privilege")) {
628
+ return { rowCount: 1, rows: [{ ok: true }] };
629
+ }
630
+ if (String(sql).includes("to_regclass")) {
631
+ return { rowCount: 1, rows: [{ ok: true }] };
632
+ }
633
+ if (String(sql).includes("has_function_privilege")) {
634
+ return { rowCount: 1, rows: [{ ok: true }] };
635
+ }
636
+ // pg_stat_statements is in pg_catalog (standard location)
637
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
638
+ return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
639
+ }
640
+ if (String(sql).includes("has_schema_privilege")) {
641
+ return { rowCount: 1, rows: [{ ok: true }] };
642
+ }
643
+
644
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
645
+ },
646
+ };
647
+
648
+ const r = await init.verifyInitSetup({
649
+ client: client as any,
650
+ database: "mydb",
651
+ monitoringUser: DEFAULT_MONITORING_USER,
652
+ includeOptionalPermissions: false,
653
+ });
654
+ // Should pass - pg_catalog doesn't need extra USAGE grant
655
+ expect(r.ok).toBe(true);
656
+ expect(r.missingRequired.length).toBe(0);
657
+ // Should NOT have queried for has_schema_privilege on pg_catalog specifically
658
+ // (the code skips the check for pg_catalog and public schemas)
659
+ const pgCatalogPrivCheck = calls.filter(
660
+ (c) => c.includes("has_schema_privilege") && c.includes("pg_catalog")
661
+ );
662
+ // Should only have the standard public schema check, not a pg_catalog check for extension
663
+ expect(pgCatalogPrivCheck.length).toBe(0);
664
+ });
665
+
666
+ test("verifyInitSetup skips extension schema check when in public", async () => {
667
+ const calls: string[] = [];
668
+ const client = {
669
+ query: async (sql: string, params?: any) => {
670
+ calls.push(String(sql));
671
+
672
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
673
+ return { rowCount: 1, rows: [] };
674
+ }
675
+ if (String(sql).toLowerCase() === "rollback;") {
676
+ return { rowCount: 1, rows: [] };
677
+ }
678
+ if (String(sql).includes("select rolconfig")) {
679
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
680
+ }
681
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
682
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
683
+ }
684
+ if (String(sql).includes("has_database_privilege")) {
685
+ return { rowCount: 1, rows: [{ ok: true }] };
686
+ }
687
+ if (String(sql).includes("pg_has_role")) {
688
+ return { rowCount: 1, rows: [{ ok: true }] };
689
+ }
690
+ if (String(sql).includes("has_table_privilege")) {
691
+ return { rowCount: 1, rows: [{ ok: true }] };
692
+ }
693
+ if (String(sql).includes("to_regclass")) {
694
+ return { rowCount: 1, rows: [{ ok: true }] };
695
+ }
696
+ if (String(sql).includes("has_function_privilege")) {
697
+ return { rowCount: 1, rows: [{ ok: true }] };
698
+ }
699
+ // pg_stat_statements is in public schema
700
+ if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
701
+ return { rowCount: 1, rows: [{ schema: "public" }] };
702
+ }
703
+ if (String(sql).includes("has_schema_privilege")) {
704
+ return { rowCount: 1, rows: [{ ok: true }] };
705
+ }
706
+
707
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
708
+ },
709
+ };
710
+
711
+ const r = await init.verifyInitSetup({
712
+ client: client as any,
713
+ database: "mydb",
714
+ monitoringUser: DEFAULT_MONITORING_USER,
715
+ includeOptionalPermissions: false,
716
+ });
717
+ // Should pass - public doesn't need extra USAGE grant for extension
718
+ expect(r.ok).toBe(true);
719
+ expect(r.missingRequired.length).toBe(0);
720
+ });
721
+
385
722
  test("buildInitPlan preserves comments when filtering ALTER USER", async () => {
386
723
  const plan = await init.buildInitPlan({
387
724
  database: "mydb",