postgresai 0.14.0-beta.11 → 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/README.md +32 -0
- package/bin/postgres-ai.ts +928 -170
- package/dist/bin/postgres-ai.js +2252 -493
- package/lib/checkup.ts +69 -3
- package/lib/init.ts +76 -19
- package/lib/issues.ts +453 -7
- package/lib/mcp-server.ts +180 -3
- package/lib/metrics-embedded.ts +3 -3
- package/lib/supabase.ts +824 -0
- package/package.json +1 -1
- package/test/checkup.test.ts +240 -14
- package/test/config-consistency.test.ts +36 -0
- package/test/init.integration.test.ts +80 -71
- package/test/init.test.ts +266 -1
- package/test/issues.cli.test.ts +224 -0
- package/test/mcp-server.test.ts +551 -12
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +6 -0
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()
|
|
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
|
|
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
|
|
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
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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:
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|