Skip to content

Commit 76ea1c9

Browse files
committed
feat(prepare-db): add helper functions and per-step transactions
- Add postgres_ai schema for organizing our objects - Move pg_statistic view from public to postgres_ai schema - Add postgres_ai.explain_generic() function (SECURITY DEFINER) for collecting generic query plans with optional HypoPG index testing - Add postgres_ai.table_describe() function (SECURITY DEFINER) for collecting comprehensive table information for LLM analysis: - Table metadata (type, pages, estimated rows) - Column definitions with types, nullability, defaults - Indexes (primary key, unique, regular) - Constraints (FK, unique, check) - Foreign keys referencing this table - Vacuum/analyze statistics - Update search_path to include postgres_ai first - Change transaction model to wrap each step in its own begin/commit instead of grouping non-optional steps in a single transaction - Update verification to check for postgres_ai schema and new functions Relates to: https://gitlab.com/postgres-ai/postgres_ai/-/issues/68
1 parent 08f1e7d commit 76ea1c9

4 files changed

Lines changed: 481 additions & 76 deletions

File tree

cli/lib/init.ts

Lines changed: 92 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,12 @@ end $$;`;
485485
sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
486486
});
487487

488+
// Helper functions (SECURITY DEFINER) for plan analysis and table info
489+
steps.push({
490+
name: "05.helpers",
491+
sql: applyTemplate(loadSqlTemplate("05.helpers.sql"), vars),
492+
});
493+
488494
if (params.includeOptionalPermissions) {
489495
steps.push(
490496
{
@@ -511,78 +517,70 @@ export async function applyInitPlan(params: {
511517
const applied: string[] = [];
512518
const skippedOptional: string[] = [];
513519

514-
// Apply non-optional steps in a single transaction.
515-
await params.client.query("begin;");
516-
try {
517-
for (const step of params.plan.steps.filter((s) => !s.optional)) {
520+
// Helper to wrap a step execution in begin/commit
521+
const executeStep = async (step: InitStep): Promise<void> => {
522+
await params.client.query("begin;");
523+
try {
524+
await params.client.query(step.sql, step.params as any);
525+
await params.client.query("commit;");
526+
} catch (e) {
527+
// Rollback errors should never mask the original failure.
518528
try {
519-
await params.client.query(step.sql, step.params as any);
520-
applied.push(step.name);
521-
} catch (e) {
522-
const msg = e instanceof Error ? e.message : String(e);
523-
const errAny = e as any;
524-
const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
525-
// Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
526-
const pgErrorFields = [
527-
"code",
528-
"detail",
529-
"hint",
530-
"position",
531-
"internalPosition",
532-
"internalQuery",
533-
"where",
534-
"schema",
535-
"table",
536-
"column",
537-
"dataType",
538-
"constraint",
539-
"file",
540-
"line",
541-
"routine",
542-
] as const;
543-
if (errAny && typeof errAny === "object") {
544-
for (const field of pgErrorFields) {
545-
if (errAny[field] !== undefined) wrapped[field] = errAny[field];
546-
}
547-
}
548-
if (e instanceof Error && e.stack) {
549-
wrapped.stack = e.stack;
550-
}
551-
throw wrapped;
529+
await params.client.query("rollback;");
530+
} catch {
531+
// ignore
552532
}
533+
throw e;
553534
}
554-
await params.client.query("commit;");
555-
} catch (e) {
556-
// Rollback errors should never mask the original failure.
535+
};
536+
537+
// Apply non-optional steps, each in its own transaction
538+
for (const step of params.plan.steps.filter((s) => !s.optional)) {
557539
try {
558-
await params.client.query("rollback;");
559-
} catch {
560-
// ignore
540+
await executeStep(step);
541+
applied.push(step.name);
542+
} catch (e) {
543+
const msg = e instanceof Error ? e.message : String(e);
544+
const errAny = e as any;
545+
const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
546+
// Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
547+
const pgErrorFields = [
548+
"code",
549+
"detail",
550+
"hint",
551+
"position",
552+
"internalPosition",
553+
"internalQuery",
554+
"where",
555+
"schema",
556+
"table",
557+
"column",
558+
"dataType",
559+
"constraint",
560+
"file",
561+
"line",
562+
"routine",
563+
] as const;
564+
if (errAny && typeof errAny === "object") {
565+
for (const field of pgErrorFields) {
566+
if (errAny[field] !== undefined) wrapped[field] = errAny[field];
567+
}
568+
}
569+
if (e instanceof Error && e.stack) {
570+
wrapped.stack = e.stack;
571+
}
572+
throw wrapped;
561573
}
562-
throw e;
563574
}
564575

565-
// Apply optional steps outside of the transaction so a failure doesn't abort everything.
576+
// Apply optional steps, each in its own transaction (failure doesn't abort)
566577
for (const step of params.plan.steps.filter((s) => s.optional)) {
567578
try {
568-
// Run each optional step in its own mini-transaction to avoid partial application.
569-
await params.client.query("begin;");
570-
try {
571-
await params.client.query(step.sql, step.params as any);
572-
await params.client.query("commit;");
573-
applied.push(step.name);
574-
} catch {
575-
try {
576-
await params.client.query("rollback;");
577-
} catch {
578-
// ignore rollback errors
579-
}
580-
skippedOptional.push(step.name);
581-
// best-effort: ignore
582-
}
579+
await executeStep(step);
580+
applied.push(step.name);
583581
} catch {
584-
// If we can't even begin/commit, treat as skipped.
585582
skippedOptional.push(step.name);
583+
// best-effort: ignore
586584
}
587585
}
588586

@@ -642,16 +640,25 @@ export async function verifyInitSetup(params: {
642640
missingRequired.push("SELECT on pg_catalog.pg_index");
643641
}
644642

645-
const viewExistsRes = await params.client.query("select to_regclass('public.pg_statistic') is not null as ok");
643+
// Check postgres_ai schema exists and is usable
644+
const schemaExistsRes = await params.client.query(
645+
"select has_schema_privilege($1, 'postgres_ai', 'USAGE') as ok",
646+
[role]
647+
);
648+
if (!schemaExistsRes.rows?.[0]?.ok) {
649+
missingRequired.push("USAGE on schema postgres_ai");
650+
}
651+
652+
const viewExistsRes = await params.client.query("select to_regclass('postgres_ai.pg_statistic') is not null as ok");
646653
if (!viewExistsRes.rows?.[0]?.ok) {
647-
missingRequired.push("view public.pg_statistic exists");
654+
missingRequired.push("view postgres_ai.pg_statistic exists");
648655
} else {
649656
const viewPrivRes = await params.client.query(
650-
"select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
657+
"select has_table_privilege($1, 'postgres_ai.pg_statistic', 'SELECT') as ok",
651658
[role]
652659
);
653660
if (!viewPrivRes.rows?.[0]?.ok) {
654-
missingRequired.push("SELECT on view public.pg_statistic");
661+
missingRequired.push("SELECT on view postgres_ai.pg_statistic");
655662
}
656663
}
657664

@@ -669,13 +676,30 @@ export async function verifyInitSetup(params: {
669676
if (typeof spLine !== "string" || !spLine) {
670677
missingRequired.push("role search_path is set");
671678
} else {
672-
// We accept any ordering as long as public and pg_catalog are included.
679+
// We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
673680
const sp = spLine.toLowerCase();
674-
if (!sp.includes("public") || !sp.includes("pg_catalog")) {
675-
missingRequired.push("role search_path includes public and pg_catalog");
681+
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
682+
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
676683
}
677684
}
678685

686+
// Check for helper functions
687+
const explainFnRes = await params.client.query(
688+
"select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok",
689+
[role]
690+
);
691+
if (!explainFnRes.rows?.[0]?.ok) {
692+
missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
693+
}
694+
695+
const tableDescribeFnRes = await params.client.query(
696+
"select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok",
697+
[role]
698+
);
699+
if (!tableDescribeFnRes.rows?.[0]?.ok) {
700+
missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
701+
}
702+
679703
if (params.includeOptionalPermissions) {
680704
// Optional RDS/Aurora extras
681705
{

cli/sql/02.permissions.sql

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ grant connect on database {{DB_IDENT}} to {{ROLE_IDENT}};
77
grant pg_monitor to {{ROLE_IDENT}};
88
grant select on pg_catalog.pg_index to {{ROLE_IDENT}};
99

10-
-- Optional, for bloat analysis: expose pg_statistic via a view
11-
create or replace view public.pg_statistic as
10+
-- Create postgres_ai schema for our objects
11+
create schema if not exists postgres_ai;
12+
grant usage on schema postgres_ai to {{ROLE_IDENT}};
13+
14+
-- For bloat analysis: expose pg_statistic via a view
15+
create or replace view postgres_ai.pg_statistic as
1216
select
1317
n.nspname as schemaname,
1418
c.relname as tablename,
@@ -22,12 +26,12 @@ join pg_catalog.pg_namespace n on n.oid = c.relnamespace
2226
join pg_catalog.pg_attribute a on a.attrelid = s.starelid and a.attnum = s.staattnum
2327
where a.attnum > 0 and not a.attisdropped;
2428

25-
grant select on public.pg_statistic to {{ROLE_IDENT}};
29+
grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
2630

2731
-- Hardened clusters sometimes revoke PUBLIC on schema public
2832
grant usage on schema public to {{ROLE_IDENT}};
2933

30-
-- Keep search_path predictable
31-
alter user {{ROLE_IDENT}} set search_path = "$user", public, pg_catalog;
34+
-- Keep search_path predictable; postgres_ai first so our objects are found
35+
alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
3236

3337

0 commit comments

Comments
 (0)