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.
- package/README.md +32 -0
- package/bin/postgres-ai.ts +1234 -170
- package/dist/bin/postgres-ai.js +2480 -410
- package/dist/sql/02.extensions.sql +8 -0
- package/dist/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
- package/dist/sql/sql/02.extensions.sql +8 -0
- package/dist/sql/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
- package/dist/sql/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/sql/uninit/03.role.sql +27 -0
- package/dist/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/uninit/03.role.sql +27 -0
- package/lib/checkup.ts +69 -3
- package/lib/init.ts +184 -26
- 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/sql/02.extensions.sql +8 -0
- package/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
- package/sql/uninit/01.helpers.sql +5 -0
- package/sql/uninit/02.permissions.sql +30 -0
- package/sql/uninit/03.role.sql +27 -0
- 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 +501 -2
- 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/dist/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
- /package/dist/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
- /package/dist/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
- /package/dist/sql/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
- /package/dist/sql/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
- /package/dist/sql/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
- /package/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
- /package/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
- /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,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,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()
|
|
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,30 +523,54 @@ 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
|
+
}
|
|
496
529
|
|
|
530
|
+
// Extensions should be created before permissions (so we can grant permissions on them)
|
|
497
531
|
steps.push({
|
|
498
|
-
name: "02.
|
|
499
|
-
sql:
|
|
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: "
|
|
505
|
-
sql: applyTemplate(loadSqlTemplate("
|
|
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: "
|
|
512
|
-
sql: applyTemplate(loadSqlTemplate("
|
|
567
|
+
name: "04.optional_rds",
|
|
568
|
+
sql: applyTemplate(loadSqlTemplate("04.optional_rds.sql"), vars),
|
|
513
569
|
optional: true,
|
|
514
570
|
},
|
|
515
571
|
{
|
|
516
|
-
name: "
|
|
517
|
-
sql: applyTemplate(loadSqlTemplate("
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|