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
79pub(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 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}