postgresai 0.14.0-beta.12 → 0.14.0-beta.14

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.
Files changed (42) hide show
  1. package/README.md +32 -0
  2. package/bin/postgres-ai.ts +1234 -170
  3. package/dist/bin/postgres-ai.js +2480 -410
  4. package/dist/sql/02.extensions.sql +8 -0
  5. package/dist/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
  6. package/dist/sql/sql/02.extensions.sql +8 -0
  7. package/dist/sql/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
  8. package/dist/sql/sql/uninit/01.helpers.sql +5 -0
  9. package/dist/sql/sql/uninit/02.permissions.sql +30 -0
  10. package/dist/sql/sql/uninit/03.role.sql +27 -0
  11. package/dist/sql/uninit/01.helpers.sql +5 -0
  12. package/dist/sql/uninit/02.permissions.sql +30 -0
  13. package/dist/sql/uninit/03.role.sql +27 -0
  14. package/lib/checkup.ts +69 -3
  15. package/lib/init.ts +184 -26
  16. package/lib/issues.ts +453 -7
  17. package/lib/mcp-server.ts +180 -3
  18. package/lib/metrics-embedded.ts +3 -3
  19. package/lib/supabase.ts +824 -0
  20. package/package.json +1 -1
  21. package/sql/02.extensions.sql +8 -0
  22. package/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
  23. package/sql/uninit/01.helpers.sql +5 -0
  24. package/sql/uninit/02.permissions.sql +30 -0
  25. package/sql/uninit/03.role.sql +27 -0
  26. package/test/checkup.test.ts +240 -14
  27. package/test/config-consistency.test.ts +36 -0
  28. package/test/init.integration.test.ts +80 -71
  29. package/test/init.test.ts +501 -2
  30. package/test/issues.cli.test.ts +224 -0
  31. package/test/mcp-server.test.ts +551 -12
  32. package/test/supabase.test.ts +568 -0
  33. package/test/test-utils.ts +6 -0
  34. /package/dist/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
  35. /package/dist/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
  36. /package/dist/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
  37. /package/dist/sql/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
  38. /package/dist/sql/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
  39. /package/dist/sql/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
  40. /package/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
  41. /package/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
  42. /package/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
@@ -0,0 +1,8 @@
1
+ -- Extensions required for postgres_ai monitoring
2
+
3
+ -- Enable pg_stat_statements for query performance monitoring
4
+ -- Note: Uses IF NOT EXISTS because extension may already be installed.
5
+ -- We do NOT drop this extension in unprepare-db since it may have been pre-existing.
6
+ create extension if not exists pg_stat_statements;
7
+
8
+
@@ -8,6 +8,7 @@ grant pg_monitor to {{ROLE_IDENT}};
8
8
  grant select on pg_catalog.pg_index to {{ROLE_IDENT}};
9
9
 
10
10
  -- Create postgres_ai schema for our objects
11
+ -- Using IF NOT EXISTS for idempotency - prepare-db can be run multiple times
11
12
  create schema if not exists postgres_ai;
12
13
  grant usage on schema postgres_ai to {{ROLE_IDENT}};
13
14
 
@@ -0,0 +1,8 @@
1
+ -- Extensions required for postgres_ai monitoring
2
+
3
+ -- Enable pg_stat_statements for query performance monitoring
4
+ -- Note: Uses IF NOT EXISTS because extension may already be installed.
5
+ -- We do NOT drop this extension in unprepare-db since it may have been pre-existing.
6
+ create extension if not exists pg_stat_statements;
7
+
8
+
@@ -8,6 +8,7 @@ grant pg_monitor to {{ROLE_IDENT}};
8
8
  grant select on pg_catalog.pg_index to {{ROLE_IDENT}};
9
9
 
10
10
  -- Create postgres_ai schema for our objects
11
+ -- Using IF NOT EXISTS for idempotency - prepare-db can be run multiple times
11
12
  create schema if not exists postgres_ai;
12
13
  grant usage on schema postgres_ai to {{ROLE_IDENT}};
13
14
 
@@ -0,0 +1,5 @@
1
+ -- Drop helper functions created by prepare-db (template-filled by cli/lib/init.ts)
2
+ -- Run before dropping the postgres_ai schema.
3
+
4
+ drop function if exists postgres_ai.explain_generic(text, text, text);
5
+ drop function if exists postgres_ai.table_describe(text);
@@ -0,0 +1,30 @@
1
+ -- Revoke permissions and drop objects created by prepare-db (template-filled by cli/lib/init.ts)
2
+
3
+ -- Drop the postgres_ai.pg_statistic view
4
+ drop view if exists postgres_ai.pg_statistic;
5
+
6
+ -- Drop the postgres_ai schema (CASCADE to handle any remaining objects)
7
+ drop schema if exists postgres_ai cascade;
8
+
9
+ -- Revoke permissions from the monitoring role
10
+ -- Use a DO block to handle the case where the role doesn't exist
11
+ do $$ begin
12
+ revoke pg_monitor from {{ROLE_IDENT}};
13
+ exception when undefined_object then
14
+ null; -- Role doesn't exist, nothing to revoke
15
+ end $$;
16
+
17
+ do $$ begin
18
+ revoke select on pg_catalog.pg_index from {{ROLE_IDENT}};
19
+ exception when undefined_object then
20
+ null; -- Role doesn't exist
21
+ end $$;
22
+
23
+ do $$ begin
24
+ revoke connect on database {{DB_IDENT}} from {{ROLE_IDENT}};
25
+ exception when undefined_object then
26
+ null; -- Role doesn't exist
27
+ end $$;
28
+
29
+ -- Note: USAGE on public is typically granted by default; we don't revoke it
30
+ -- to avoid breaking other applications that may rely on it.
@@ -0,0 +1,27 @@
1
+ -- Drop the monitoring role created by prepare-db (template-filled by cli/lib/init.ts)
2
+ -- This must run after revoking all permissions from the role.
3
+
4
+ -- Use a DO block to handle the case where the role doesn't exist
5
+ do $$ begin
6
+ -- Reassign owned objects to current user before dropping
7
+ -- This handles any objects that might have been created by the role
8
+ begin
9
+ execute format('reassign owned by %I to current_user', {{ROLE_LITERAL}});
10
+ exception when undefined_object then
11
+ null; -- Role doesn't exist, nothing to reassign
12
+ end;
13
+
14
+ -- Drop owned objects (in case reassign didn't work for some objects)
15
+ begin
16
+ execute format('drop owned by %I', {{ROLE_LITERAL}});
17
+ exception when undefined_object then
18
+ null; -- Role doesn't exist
19
+ end;
20
+
21
+ -- Drop the role
22
+ begin
23
+ execute format('drop role %I', {{ROLE_LITERAL}});
24
+ exception when undefined_object then
25
+ null; -- Role doesn't exist, that's fine
26
+ end;
27
+ end $$;
@@ -0,0 +1,5 @@
1
+ -- Drop helper functions created by prepare-db (template-filled by cli/lib/init.ts)
2
+ -- Run before dropping the postgres_ai schema.
3
+
4
+ drop function if exists postgres_ai.explain_generic(text, text, text);
5
+ drop function if exists postgres_ai.table_describe(text);
@@ -0,0 +1,30 @@
1
+ -- Revoke permissions and drop objects created by prepare-db (template-filled by cli/lib/init.ts)
2
+
3
+ -- Drop the postgres_ai.pg_statistic view
4
+ drop view if exists postgres_ai.pg_statistic;
5
+
6
+ -- Drop the postgres_ai schema (CASCADE to handle any remaining objects)
7
+ drop schema if exists postgres_ai cascade;
8
+
9
+ -- Revoke permissions from the monitoring role
10
+ -- Use a DO block to handle the case where the role doesn't exist
11
+ do $$ begin
12
+ revoke pg_monitor from {{ROLE_IDENT}};
13
+ exception when undefined_object then
14
+ null; -- Role doesn't exist, nothing to revoke
15
+ end $$;
16
+
17
+ do $$ begin
18
+ revoke select on pg_catalog.pg_index from {{ROLE_IDENT}};
19
+ exception when undefined_object then
20
+ null; -- Role doesn't exist
21
+ end $$;
22
+
23
+ do $$ begin
24
+ revoke connect on database {{DB_IDENT}} from {{ROLE_IDENT}};
25
+ exception when undefined_object then
26
+ null; -- Role doesn't exist
27
+ end $$;
28
+
29
+ -- Note: USAGE on public is typically granted by default; we don't revoke it
30
+ -- to avoid breaking other applications that may rely on it.
@@ -0,0 +1,27 @@
1
+ -- Drop the monitoring role created by prepare-db (template-filled by cli/lib/init.ts)
2
+ -- This must run after revoking all permissions from the role.
3
+
4
+ -- Use a DO block to handle the case where the role doesn't exist
5
+ do $$ begin
6
+ -- Reassign owned objects to current user before dropping
7
+ -- This handles any objects that might have been created by the role
8
+ begin
9
+ execute format('reassign owned by %I to current_user', {{ROLE_LITERAL}});
10
+ exception when undefined_object then
11
+ null; -- Role doesn't exist, nothing to reassign
12
+ end;
13
+
14
+ -- Drop owned objects (in case reassign didn't work for some objects)
15
+ begin
16
+ execute format('drop owned by %I', {{ROLE_LITERAL}});
17
+ exception when undefined_object then
18
+ null; -- Role doesn't exist
19
+ end;
20
+
21
+ -- Drop the role
22
+ begin
23
+ execute format('drop role %I', {{ROLE_LITERAL}});
24
+ exception when undefined_object then
25
+ null; -- Role doesn't exist, that's fine
26
+ end;
27
+ end $$;
package/lib/checkup.ts CHANGED
@@ -109,6 +109,12 @@ export interface ClusterMetric {
109
109
 
110
110
  /**
111
111
  * Invalid index entry (H001) - matches H001.schema.json invalidIndex
112
+ *
113
+ * Decision tree for remediation recommendations:
114
+ * 1. has_valid_duplicate=true → DROP (valid duplicate exists, safe to remove)
115
+ * 2. is_pk=true or is_unique=true → RECREATE (backs a constraint, must restore)
116
+ * 3. table_row_estimate < 10000 → RECREATE (small table, quick rebuild)
117
+ * 4. Otherwise → UNCERTAIN (needs manual analysis of query plans)
112
118
  */
113
119
  export interface InvalidIndex {
114
120
  schema_name: string;
@@ -117,9 +123,61 @@ export interface InvalidIndex {
117
123
  relation_name: string;
118
124
  index_size_bytes: number;
119
125
  index_size_pretty: string;
120
- /** Full CREATE INDEX statement from pg_get_indexdef(), useful for DROP/CREATE migrations */
126
+ /** Full CREATE INDEX statement from pg_get_indexdef() - useful for DROP/RECREATE migrations */
121
127
  index_definition: string;
122
128
  supports_fk: boolean;
129
+ /** True if this index backs a PRIMARY KEY constraint */
130
+ is_pk: boolean;
131
+ /** True if this is a UNIQUE index (includes PK indexes) */
132
+ is_unique: boolean;
133
+ /** Name of the constraint this index backs, or null if none */
134
+ constraint_name: string | null;
135
+ /** Estimated row count of the table from pg_class.reltuples */
136
+ table_row_estimate: number;
137
+ /** True if there is a valid index on the same column(s) */
138
+ has_valid_duplicate: boolean;
139
+ /** Name of the valid duplicate index if one exists */
140
+ valid_duplicate_name: string | null;
141
+ /** Full CREATE INDEX statement of the valid duplicate index */
142
+ valid_duplicate_definition: string | null;
143
+ }
144
+
145
+ /** Recommendation for handling an invalid index */
146
+ export type InvalidIndexRecommendation = "DROP" | "RECREATE" | "UNCERTAIN";
147
+
148
+ /** Threshold for considering a table "small" (quick to rebuild) */
149
+ const SMALL_TABLE_ROW_THRESHOLD = 10000;
150
+
151
+ /**
152
+ * Compute remediation recommendation for an invalid index using decision tree.
153
+ *
154
+ * Decision tree logic:
155
+ * 1. If has_valid_duplicate is true → DROP (valid duplicate exists, safe to remove)
156
+ * 2. If is_pk or is_unique is true → RECREATE (backs a constraint, must restore)
157
+ * 3. If table_row_estimate < 10000 → RECREATE (small table, quick rebuild)
158
+ * 4. Otherwise → UNCERTAIN (needs manual analysis of query plans)
159
+ *
160
+ * @param index - Invalid index with observation data
161
+ * @returns Recommendation: "DROP", "RECREATE", or "UNCERTAIN"
162
+ */
163
+ export function getInvalidIndexRecommendation(index: InvalidIndex): InvalidIndexRecommendation {
164
+ // 1. Valid duplicate exists - safe to drop
165
+ if (index.has_valid_duplicate) {
166
+ return "DROP";
167
+ }
168
+
169
+ // 2. Backs a constraint - must recreate
170
+ if (index.is_pk || index.is_unique) {
171
+ return "RECREATE";
172
+ }
173
+
174
+ // 3. Small table - quick to recreate
175
+ if (index.table_row_estimate < SMALL_TABLE_ROW_THRESHOLD) {
176
+ return "RECREATE";
177
+ }
178
+
179
+ // 4. Large table without clear path - needs manual analysis
180
+ return "UNCERTAIN";
123
181
  }
124
182
 
125
183
  /**
@@ -564,11 +622,11 @@ export async function getClusterInfo(client: Client, pgMajorVersion: number = 16
564
622
 
565
623
  /**
566
624
  * Get invalid indexes from the database (H001).
567
- * Invalid indexes are indexes that failed to build (e.g., due to CONCURRENTLY failure).
625
+ * Invalid indexes have indisvalid = false, typically from failed CREATE INDEX CONCURRENTLY.
568
626
  *
569
627
  * @param client - Connected PostgreSQL client
570
628
  * @param pgMajorVersion - PostgreSQL major version (default: 16)
571
- * @returns Array of invalid index entries with size and FK support info
629
+ * @returns Array of invalid index entries with observation data for decision tree analysis
572
630
  */
573
631
  export async function getInvalidIndexes(client: Client, pgMajorVersion: number = 16): Promise<InvalidIndex[]> {
574
632
  const sql = getMetricSql(METRIC_NAMES.H001, pgMajorVersion);
@@ -576,6 +634,7 @@ export async function getInvalidIndexes(client: Client, pgMajorVersion: number =
576
634
  return result.rows.map((row) => {
577
635
  const transformed = transformMetricRow(row);
578
636
  const indexSizeBytes = parseInt(String(transformed.index_size_bytes || 0), 10);
637
+
579
638
  return {
580
639
  schema_name: String(transformed.schema_name || ""),
581
640
  table_name: String(transformed.table_name || ""),
@@ -585,6 +644,13 @@ export async function getInvalidIndexes(client: Client, pgMajorVersion: number =
585
644
  index_size_pretty: formatBytes(indexSizeBytes),
586
645
  index_definition: String(transformed.index_definition || ""),
587
646
  supports_fk: toBool(transformed.supports_fk),
647
+ is_pk: toBool(transformed.is_pk),
648
+ is_unique: toBool(transformed.is_unique),
649
+ constraint_name: transformed.constraint_name ? String(transformed.constraint_name) : null,
650
+ table_row_estimate: parseInt(String(transformed.table_row_estimate || 0), 10),
651
+ has_valid_duplicate: toBool(transformed.has_valid_duplicate),
652
+ valid_duplicate_name: transformed.valid_index_name ? String(transformed.valid_index_name) : null,
653
+ valid_duplicate_definition: transformed.valid_index_definition ? String(transformed.valid_index_definition) : null,
588
654
  };
589
655
  });
590
656
  }
package/lib/init.ts CHANGED
@@ -7,6 +7,32 @@ import * as path from "path";
7
7
 
8
8
  export const DEFAULT_MONITORING_USER = "postgres_ai_mon";
9
9
 
10
+ /**
11
+ * Database provider type. Affects which prepare-db steps are executed.
12
+ * Known providers have specific behavior adjustments; unknown providers use default behavior.
13
+ * TODO: Consider auto-detecting provider from connection string or server version string.
14
+ * TODO: Consider making this more flexible via a config that specifies which steps/checks to skip.
15
+ */
16
+ export type DbProvider = string;
17
+
18
+ /** Known providers with special handling. Unknown providers are treated as self-managed. */
19
+ export const KNOWN_PROVIDERS = ["self-managed", "supabase"] as const;
20
+
21
+ /** Providers where we skip role creation (users managed externally). */
22
+ const SKIP_ROLE_CREATION_PROVIDERS = ["supabase"];
23
+
24
+ /** Providers where we skip ALTER USER statements (restricted by provider). */
25
+ const SKIP_ALTER_USER_PROVIDERS = ["supabase"];
26
+
27
+ /** Providers where we skip search_path verification (not set via ALTER USER). */
28
+ const SKIP_SEARCH_PATH_CHECK_PROVIDERS = ["supabase"];
29
+
30
+ /** Check if a provider is known and return a warning message if not. */
31
+ export function validateProvider(provider: string | undefined): string | null {
32
+ if (!provider || KNOWN_PROVIDERS.includes(provider as any)) return null;
33
+ return `Unknown provider "${provider}". Known providers: ${KNOWN_PROVIDERS.join(", ")}. Treating as self-managed.`;
34
+ }
35
+
10
36
  export type PgClientConfig = {
11
37
  connectionString?: string;
12
38
  host?: string;
@@ -458,10 +484,13 @@ export async function buildInitPlan(params: {
458
484
  monitoringUser?: string;
459
485
  monitoringPassword: string;
460
486
  includeOptionalPermissions: boolean;
487
+ /** Provider type. Affects which steps are included. Defaults to "self-managed". */
488
+ provider?: DbProvider;
461
489
  }): Promise<InitPlan> {
462
490
  // NOTE: kept async for API stability / potential future async template loading.
463
491
  const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
464
492
  const database = params.database;
493
+ const provider = params.provider ?? "self-managed";
465
494
 
466
495
  const qRole = quoteIdent(monitoringUser);
467
496
  const qDb = quoteIdent(database);
@@ -475,12 +504,15 @@ export async function buildInitPlan(params: {
475
504
  DB_IDENT: qDb,
476
505
  };
477
506
 
478
- // Role creation/update is done in one template file.
479
- // Always use a single DO block to avoid race conditions between "role exists?" checks and CREATE USER.
480
- // We:
481
- // - create role if missing (and handle duplicate_object in case another session created it concurrently),
482
- // - then ALTER ROLE to ensure the password is set to the desired value.
483
- const roleStmt = `do $$ begin
507
+ // Some providers (e.g., Supabase) manage users externally - skip role creation.
508
+ // TODO: Make this more flexible by allowing users to specify which steps to skip via config.
509
+ if (!SKIP_ROLE_CREATION_PROVIDERS.includes(provider)) {
510
+ // Role creation/update is done in one template file.
511
+ // Always use a single DO block to avoid race conditions between "role exists?" checks and CREATE USER.
512
+ // We:
513
+ // - create role if missing (and handle duplicate_object in case another session created it concurrently),
514
+ // - then ALTER ROLE to ensure the password is set to the desired value.
515
+ const roleStmt = `do $$ begin
484
516
  if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
485
517
  begin
486
518
  create user ${qRole} with password ${qPw};
@@ -491,30 +523,54 @@ export async function buildInitPlan(params: {
491
523
  alter user ${qRole} with password ${qPw};
492
524
  end $$;`;
493
525
 
494
- const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
495
- steps.push({ name: "01.role", sql: roleSql });
526
+ const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
527
+ steps.push({ name: "01.role", sql: roleSql });
528
+ }
496
529
 
530
+ // Extensions should be created before permissions (so we can grant permissions on them)
497
531
  steps.push({
498
- name: "02.permissions",
499
- sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
532
+ name: "02.extensions",
533
+ sql: loadSqlTemplate("02.extensions.sql"),
534
+ });
535
+
536
+ let permissionsSql = applyTemplate(loadSqlTemplate("03.permissions.sql"), vars);
537
+
538
+ // Some providers restrict ALTER USER - remove those statements.
539
+ // TODO: Make this more flexible by allowing users to specify which statements to skip via config.
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");
551
+ }
552
+
553
+ steps.push({
554
+ name: "03.permissions",
555
+ sql: permissionsSql,
500
556
  });
501
557
 
502
558
  // Helper functions (SECURITY DEFINER) for plan analysis and table info
503
559
  steps.push({
504
- name: "05.helpers",
505
- sql: applyTemplate(loadSqlTemplate("05.helpers.sql"), vars),
560
+ name: "06.helpers",
561
+ sql: applyTemplate(loadSqlTemplate("06.helpers.sql"), vars),
506
562
  });
507
563
 
508
564
  if (params.includeOptionalPermissions) {
509
565
  steps.push(
510
566
  {
511
- name: "03.optional_rds",
512
- sql: applyTemplate(loadSqlTemplate("03.optional_rds.sql"), vars),
567
+ name: "04.optional_rds",
568
+ sql: applyTemplate(loadSqlTemplate("04.optional_rds.sql"), vars),
513
569
  optional: true,
514
570
  },
515
571
  {
516
- name: "04.optional_self_managed",
517
- sql: applyTemplate(loadSqlTemplate("04.optional_self_managed.sql"), vars),
572
+ name: "05.optional_self_managed",
573
+ sql: applyTemplate(loadSqlTemplate("05.optional_self_managed.sql"), vars),
518
574
  optional: true,
519
575
  }
520
576
  );
@@ -607,11 +663,108 @@ export type VerifyInitResult = {
607
663
  missingOptional: string[];
608
664
  };
609
665
 
666
+ export type UninitPlan = {
667
+ monitoringUser: string;
668
+ database: string;
669
+ steps: InitStep[];
670
+ /** If true, also drop the monitoring role. If false, only revoke permissions. */
671
+ dropRole: boolean;
672
+ };
673
+
674
+ export async function buildUninitPlan(params: {
675
+ database: string;
676
+ monitoringUser?: string;
677
+ /** If true, drop the role entirely. If false, only revoke permissions/drop objects. */
678
+ dropRole?: boolean;
679
+ /** Provider type. Affects which steps are included. Defaults to "self-managed". */
680
+ provider?: DbProvider;
681
+ }): Promise<UninitPlan> {
682
+ const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
683
+ const database = params.database;
684
+ const provider = params.provider ?? "self-managed";
685
+ const dropRole = params.dropRole ?? true;
686
+
687
+ const qRole = quoteIdent(monitoringUser);
688
+ const qDb = quoteIdent(database);
689
+ const qRoleLiteral = quoteLiteral(monitoringUser);
690
+
691
+ const steps: InitStep[] = [];
692
+
693
+ const vars: Record<string, string> = {
694
+ ROLE_IDENT: qRole,
695
+ DB_IDENT: qDb,
696
+ ROLE_LITERAL: qRoleLiteral,
697
+ };
698
+
699
+ // Step 1: Drop helper functions
700
+ steps.push({
701
+ name: "01.drop_helpers",
702
+ sql: applyTemplate(loadSqlTemplate("uninit/01.helpers.sql"), vars),
703
+ });
704
+
705
+ // Step 2: Drop view, revoke permissions, drop schema
706
+ steps.push({
707
+ name: "02.revoke_permissions",
708
+ sql: applyTemplate(loadSqlTemplate("uninit/02.permissions.sql"), vars),
709
+ });
710
+
711
+ // Step 3: Drop the role (only if requested and provider allows it)
712
+ if (dropRole && !SKIP_ROLE_CREATION_PROVIDERS.includes(provider)) {
713
+ steps.push({
714
+ name: "03.drop_role",
715
+ sql: applyTemplate(loadSqlTemplate("uninit/03.role.sql"), vars),
716
+ });
717
+ }
718
+
719
+ return { monitoringUser, database, steps, dropRole };
720
+ }
721
+
722
+ export async function applyUninitPlan(params: {
723
+ client: PgClient;
724
+ plan: UninitPlan;
725
+ }): Promise<{ applied: string[]; errors: string[] }> {
726
+ const applied: string[] = [];
727
+ const errors: string[] = [];
728
+
729
+ // Helper to wrap a step execution in begin/commit
730
+ const executeStep = async (step: InitStep): Promise<void> => {
731
+ await params.client.query("begin;");
732
+ try {
733
+ await params.client.query(step.sql, step.params as any);
734
+ await params.client.query("commit;");
735
+ } catch (e) {
736
+ try {
737
+ await params.client.query("rollback;");
738
+ } catch {
739
+ // ignore
740
+ }
741
+ throw e;
742
+ }
743
+ };
744
+
745
+ // Apply steps in order - unlike init, uninit steps are not optional
746
+ // but we continue on errors to clean up as much as possible
747
+ for (const step of params.plan.steps) {
748
+ try {
749
+ await executeStep(step);
750
+ applied.push(step.name);
751
+ } catch (e) {
752
+ const msg = e instanceof Error ? e.message : String(e);
753
+ errors.push(`${step.name}: ${msg}`);
754
+ // Continue to try other steps
755
+ }
756
+ }
757
+
758
+ return { applied, errors };
759
+ }
760
+
610
761
  export async function verifyInitSetup(params: {
611
762
  client: PgClient;
612
763
  database: string;
613
764
  monitoringUser: string;
614
765
  includeOptionalPermissions: boolean;
766
+ /** Provider type. Affects which checks are performed. */
767
+ provider?: DbProvider;
615
768
  }): Promise<VerifyInitResult> {
616
769
  // Use a repeatable-read snapshot so all checks see a consistent view.
617
770
  await params.client.query("begin isolation level repeatable read;");
@@ -621,6 +774,7 @@ export async function verifyInitSetup(params: {
621
774
 
622
775
  const role = params.monitoringUser;
623
776
  const db = params.database;
777
+ const provider = params.provider ?? "self-managed";
624
778
 
625
779
  const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
626
780
  const roleExists = (roleRes.rowCount ?? 0) > 0;
@@ -684,16 +838,20 @@ export async function verifyInitSetup(params: {
684
838
  missingRequired.push("USAGE on schema public");
685
839
  }
686
840
 
687
- const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
688
- const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
689
- const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
690
- if (typeof spLine !== "string" || !spLine) {
691
- missingRequired.push("role search_path is set");
692
- } else {
693
- // We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
694
- const sp = spLine.toLowerCase();
695
- if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
696
- missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
841
+ // Some providers don't allow setting search_path via ALTER USER - skip this check.
842
+ // TODO: Make this more flexible by allowing users to specify which checks to skip via config.
843
+ if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
844
+ const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
845
+ const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
846
+ const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
847
+ if (typeof spLine !== "string" || !spLine) {
848
+ missingRequired.push("role search_path is set");
849
+ } else {
850
+ // We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
851
+ const sp = spLine.toLowerCase();
852
+ if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
853
+ missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
854
+ }
697
855
  }
698
856
  }
699
857