postgresai 0.14.0-beta.12 → 0.14.0-beta.13

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/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,12 +523,30 @@ 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
+ }
529
+
530
+ let permissionsSql = applyTemplate(loadSqlTemplate("02.permissions.sql"), vars);
531
+
532
+ // Some providers restrict ALTER USER - remove those statements.
533
+ // TODO: Make this more flexible by allowing users to specify which statements to skip via config.
534
+ if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
535
+ permissionsSql = permissionsSql
536
+ .split("\n")
537
+ .filter((line) => {
538
+ const trimmed = line.trim();
539
+ // Keep comments and empty lines
540
+ if (trimmed.startsWith("--") || trimmed === "") return true;
541
+ // Filter out ALTER USER statements (case-insensitive, flexible whitespace)
542
+ return !/^\s*alter\s+user\s+/i.test(line);
543
+ })
544
+ .join("\n");
545
+ }
496
546
 
497
547
  steps.push({
498
548
  name: "02.permissions",
499
- sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
549
+ sql: permissionsSql,
500
550
  });
501
551
 
502
552
  // Helper functions (SECURITY DEFINER) for plan analysis and table info
@@ -612,6 +662,8 @@ export async function verifyInitSetup(params: {
612
662
  database: string;
613
663
  monitoringUser: string;
614
664
  includeOptionalPermissions: boolean;
665
+ /** Provider type. Affects which checks are performed. */
666
+ provider?: DbProvider;
615
667
  }): Promise<VerifyInitResult> {
616
668
  // Use a repeatable-read snapshot so all checks see a consistent view.
617
669
  await params.client.query("begin isolation level repeatable read;");
@@ -621,6 +673,7 @@ export async function verifyInitSetup(params: {
621
673
 
622
674
  const role = params.monitoringUser;
623
675
  const db = params.database;
676
+ const provider = params.provider ?? "self-managed";
624
677
 
625
678
  const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
626
679
  const roleExists = (roleRes.rowCount ?? 0) > 0;
@@ -684,16 +737,20 @@ export async function verifyInitSetup(params: {
684
737
  missingRequired.push("USAGE on schema public");
685
738
  }
686
739
 
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");
740
+ // Some providers don't allow setting search_path via ALTER USER - skip this check.
741
+ // TODO: Make this more flexible by allowing users to specify which checks to skip via config.
742
+ if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
743
+ const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
744
+ const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
745
+ const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
746
+ if (typeof spLine !== "string" || !spLine) {
747
+ missingRequired.push("role search_path is set");
748
+ } else {
749
+ // We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
750
+ const sp = spLine.toLowerCase();
751
+ if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
752
+ missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
753
+ }
697
754
  }
698
755
  }
699
756