Skip to main content

gobby_code/setup/
postgres.rs

1use super::contracts::{
2    DEFAULT_SCHEMA, INDEX_CONTRACTS, IndexContract, OVERWRITE_GUIDANCE, TABLE_CONTRACTS,
3    TableContract, code_index_index_names, code_index_table_names,
4};
5use super::ddl::GcodeStandaloneSetup;
6use super::identifiers::qualified_relation;
7use super::types::{StandaloneFailure, StandaloneSetupRequest, StandaloneSetupStatus};
8use gobby_core::setup::{SetupContext, SetupError, SetupReport, StandaloneSetup};
9use postgres::{Client, GenericClient};
10use std::collections::HashSet;
11
12pub fn run_standalone_setup(
13    request: &StandaloneSetupRequest,
14    client: &mut Client,
15) -> Result<StandaloneSetupStatus, SetupError> {
16    validate_standalone_request(request)?;
17
18    let setup = GcodeStandaloneSetup::new(request.schema.clone());
19    let mut tx = client
20        .transaction()
21        .map_err(|err| SetupError::CreationFailed {
22            object: "standalone setup transaction".to_string(),
23            message: err.to_string(),
24        })?;
25    let mut reset_report = SetupReport::default();
26    if request.overwrite_code_index {
27        if let Err(err) = reset_postgres_code_index(&mut tx, setup.schema()) {
28            reset_report
29                .failed
30                .push(("code-index overwrite reset".to_string(), err.to_string()));
31            return Ok(standalone_setup_status(&setup, reset_report));
32        }
33    } else {
34        ensure_postgres_code_index_compatible(&mut tx, setup.schema())?;
35    }
36
37    let mut report = {
38        let mut ctx = SetupContext {
39            pg: Some(&mut tx),
40            falkor_config: None,
41            qdrant_config: None,
42            non_interactive: true,
43        };
44        setup.create(&mut ctx)
45    }?;
46    if report.failed.is_empty() {
47        tx.commit().map_err(|err| SetupError::CreationFailed {
48            object: "standalone setup commit".to_string(),
49            message: err.to_string(),
50        })?;
51    } else {
52        report.created.clear();
53        report.skipped.clear();
54    }
55
56    Ok(standalone_setup_status(&setup, report))
57}
58
59fn standalone_setup_status(
60    setup: &GcodeStandaloneSetup,
61    report: SetupReport,
62) -> StandaloneSetupStatus {
63    StandaloneSetupStatus {
64        namespace: setup.namespace().to_string(),
65        schema: setup.schema().to_string(),
66        created: report.created,
67        skipped: report.skipped,
68        failed: report
69            .failed
70            .into_iter()
71            .map(|(name, reason)| StandaloneFailure { name, reason })
72            .collect(),
73        config_file: None,
74        services: None,
75        embedding: None,
76    }
77}
78
79/// The standalone setup target is PostgreSQL 18 with `pg_search` BM25 indexes.
80///
81/// Compatibility checks intentionally inspect PostgreSQL catalogs
82/// (`pg_class`, `pg_namespace`, `pg_attribute`, `pg_index`, `pg_am`) so gcode
83/// can validate only the code-index objects it owns before creating or
84/// overwriting anything.
85pub(crate) fn ensure_postgres_code_index_compatible(
86    client: &mut impl GenericClient,
87    schema: &str,
88) -> Result<(), SetupError> {
89    let issues = incompatible_postgres_code_index_relations(client, schema)?;
90    if issues.is_empty() {
91        return Ok(());
92    }
93
94    Err(SetupError::CreationFailed {
95        object: "code-index preflight".to_string(),
96        message: format!(
97            "existing code-index PostgreSQL state is incompatible: {}. {OVERWRITE_GUIDANCE}",
98            issues.join("; ")
99        ),
100    })
101}
102
103pub(crate) fn reset_postgres_code_index(
104    client: &mut impl GenericClient,
105    schema: &str,
106) -> Result<(), SetupError> {
107    let sql = postgres_overwrite_reset_sql(schema)?;
108    client
109        .batch_execute(&sql)
110        .map_err(|err| SetupError::CreationFailed {
111            object: "code-index overwrite reset".to_string(),
112            message: err.to_string(),
113        })
114}
115
116pub(crate) fn postgres_overwrite_reset_sql(schema: &str) -> Result<String, SetupError> {
117    let mut statements = Vec::new();
118    for index in code_index_index_names() {
119        statements.push(format!(
120            "DROP INDEX IF EXISTS {};",
121            qualified_relation(schema, index, "index")?
122        ));
123    }
124    for table in code_index_table_names().rev() {
125        statements.push(format!(
126            "DROP TABLE IF EXISTS {};",
127            qualified_relation(schema, table, "table")?
128        ));
129    }
130    Ok(statements.join("\n"))
131}
132
133fn incompatible_postgres_code_index_relations(
134    client: &mut impl GenericClient,
135    schema: &str,
136) -> Result<Vec<String>, SetupError> {
137    let mut issues = Vec::new();
138    for contract in TABLE_CONTRACTS {
139        inspect_table_contract(client, schema, contract, &mut issues)?;
140    }
141    for contract in INDEX_CONTRACTS {
142        inspect_index_contract(client, schema, contract, &mut issues)?;
143    }
144    Ok(issues)
145}
146
147fn inspect_table_contract(
148    client: &mut impl GenericClient,
149    schema: &str,
150    contract: &TableContract,
151    issues: &mut Vec<String>,
152) -> Result<(), SetupError> {
153    let Some(kind) = relation_kind(client, schema, contract.name)? else {
154        return Ok(());
155    };
156    if kind != "r" {
157        issues.push(format!(
158            "{} exists but is not an ordinary table",
159            contract.name
160        ));
161        return Ok(());
162    }
163
164    let existing = table_columns(client, schema, contract.name)?;
165    let missing = contract
166        .required_columns
167        .iter()
168        .filter(|column| !existing.contains::<str>(column))
169        .copied()
170        .collect::<Vec<_>>();
171    if !missing.is_empty() {
172        issues.push(format!(
173            "{} is missing column(s): {}",
174            contract.name,
175            missing.join(", ")
176        ));
177    }
178    Ok(())
179}
180
181fn inspect_index_contract(
182    client: &mut impl GenericClient,
183    schema: &str,
184    contract: &IndexContract,
185    issues: &mut Vec<String>,
186) -> Result<(), SetupError> {
187    let Some(index) = index_info(client, schema, contract.name)? else {
188        return Ok(());
189    };
190
191    if index.relkind != "i" && index.relkind != "I" {
192        issues.push(format!("{} exists but is not an index", contract.name));
193        return Ok(());
194    }
195    if index.table_name.as_deref() != Some(contract.table) {
196        issues.push(format!(
197            "{} is attached to {}, expected {}",
198            contract.name,
199            index.table_name.as_deref().unwrap_or("<unknown>"),
200            contract.table
201        ));
202    }
203    if index.method.as_deref() != Some(contract.method) {
204        issues.push(format!(
205            "{} uses access method {}, expected {}",
206            contract.name,
207            index.method.as_deref().unwrap_or("<unknown>"),
208            contract.method
209        ));
210    }
211    Ok(())
212}
213
214fn relation_kind(
215    client: &mut impl GenericClient,
216    schema: &str,
217    relation: &str,
218) -> Result<Option<String>, SetupError> {
219    let row = client
220        .query_opt(
221            "SELECT c.relkind::TEXT
222             FROM pg_class c
223             JOIN pg_namespace n ON n.oid = c.relnamespace
224             WHERE n.nspname = $1 AND c.relname = $2",
225            &[&schema, &relation],
226        )
227        .map_err(|err| SetupError::CreationFailed {
228            object: format!("{relation} preflight"),
229            message: err.to_string(),
230        })?;
231    Ok(row.map(|row| row.get(0)))
232}
233
234fn table_columns(
235    client: &mut impl GenericClient,
236    schema: &str,
237    table: &str,
238) -> Result<HashSet<String>, SetupError> {
239    let rows = client
240        .query(
241            "SELECT a.attname
242             FROM pg_attribute a
243             JOIN pg_class c ON c.oid = a.attrelid
244             JOIN pg_namespace n ON n.oid = c.relnamespace
245             WHERE n.nspname = $1
246               AND c.relname = $2
247               AND a.attnum > 0
248               AND NOT a.attisdropped",
249            &[&schema, &table],
250        )
251        .map_err(|err| SetupError::CreationFailed {
252            object: format!("{table} preflight"),
253            message: err.to_string(),
254        })?;
255    Ok(rows.into_iter().map(|row| row.get(0)).collect())
256}
257
258struct ExistingIndexInfo {
259    relkind: String,
260    table_name: Option<String>,
261    method: Option<String>,
262}
263
264fn index_info(
265    client: &mut impl GenericClient,
266    schema: &str,
267    index: &str,
268) -> Result<Option<ExistingIndexInfo>, SetupError> {
269    let row = client
270        .query_opt(
271            "SELECT c.relkind::TEXT,
272                    table_class.relname::TEXT AS table_name,
273                    am.amname::TEXT AS method
274             FROM pg_class c
275             JOIN pg_namespace n ON n.oid = c.relnamespace
276             LEFT JOIN pg_index idx ON idx.indexrelid = c.oid
277             LEFT JOIN pg_class table_class ON table_class.oid = idx.indrelid
278             LEFT JOIN pg_am am ON am.oid = c.relam
279             WHERE n.nspname = $1 AND c.relname = $2",
280            &[&schema, &index],
281        )
282        .map_err(|err| SetupError::CreationFailed {
283            object: format!("{index} preflight"),
284            message: err.to_string(),
285        })?;
286
287    Ok(row.map(|row| ExistingIndexInfo {
288        relkind: row.get(0),
289        table_name: row.get(1),
290        method: row.get(2),
291    }))
292}
293
294pub fn validate_standalone_request(request: &StandaloneSetupRequest) -> Result<(), SetupError> {
295    if !request.standalone {
296        return Err(SetupError::AttachedModeRefused);
297    }
298    // The daemon adopts the standalone code-index tables from `public`; using a
299    // different schema would create an isolated index the daemon cannot share.
300    if request.schema != DEFAULT_SCHEMA {
301        return Err(SetupError::CreationFailed {
302            object: "schema".to_string(),
303            message: "standalone code-index schema must be `public` for daemon adoption"
304                .to_string(),
305        });
306    }
307    Ok(())
308}