1use gobby_core::setup::{
2 OwnedObject, SetupContext, SetupError, SetupReport, StandaloneSetup, StoreKind,
3};
4use postgres::{Client, GenericClient};
5use serde::{Deserialize, Serialize};
6use std::collections::HashSet;
7
8const DEFAULT_SCHEMA: &str = "public";
9const NAMESPACE: &str = "gcode";
10const OVERWRITE_GUIDANCE: &str = "Rerun with `gcode setup --standalone --overwrite-code-index` to replace only gcode-owned code-index relations.";
11
12const CODE_INDEX_TABLES: &[&str] = &[
13 "code_indexed_projects",
14 "code_indexed_files",
15 "code_symbols",
16 "code_content_chunks",
17 "code_imports",
18 "code_calls",
19];
20
21const CODE_INDEX_INDEXES: &[&str] = &[
22 "idx_cif_project",
23 "idx_cif_graph_synced",
24 "idx_cif_vectors_synced",
25 "idx_cs_project",
26 "idx_cs_file",
27 "idx_cs_name",
28 "idx_cs_qualified",
29 "idx_cs_kind",
30 "idx_cs_parent",
31 "idx_ccc_project",
32 "idx_ccc_file",
33 "idx_ci_file",
34 "idx_cc_file",
35 "idx_cc_caller",
36 "idx_cc_target",
37 "code_symbols_search_bm25",
38 "code_content_search_bm25",
39];
40
41struct TableContract {
42 name: &'static str,
43 required_columns: &'static [&'static str],
44}
45
46struct IndexContract {
47 name: &'static str,
48 table: &'static str,
49 method: &'static str,
50}
51
52const TABLE_CONTRACTS: &[TableContract] = &[
53 TableContract {
54 name: "code_indexed_projects",
55 required_columns: &[
56 "id",
57 "root_path",
58 "total_files",
59 "total_symbols",
60 "last_indexed_at",
61 "index_duration_ms",
62 "created_at",
63 "updated_at",
64 ],
65 },
66 TableContract {
67 name: "code_indexed_files",
68 required_columns: &[
69 "id",
70 "project_id",
71 "file_path",
72 "language",
73 "content_hash",
74 "symbol_count",
75 "byte_size",
76 "graph_synced",
77 "vectors_synced",
78 "graph_sync_attempted_at",
79 "indexed_at",
80 ],
81 },
82 TableContract {
83 name: "code_symbols",
84 required_columns: &[
85 "id",
86 "project_id",
87 "file_path",
88 "name",
89 "qualified_name",
90 "kind",
91 "language",
92 "byte_start",
93 "byte_end",
94 "line_start",
95 "line_end",
96 "signature",
97 "docstring",
98 "parent_symbol_id",
99 "content_hash",
100 "summary",
101 "created_at",
102 "updated_at",
103 ],
104 },
105 TableContract {
106 name: "code_content_chunks",
107 required_columns: &[
108 "id",
109 "project_id",
110 "file_path",
111 "chunk_index",
112 "line_start",
113 "line_end",
114 "content",
115 "language",
116 "created_at",
117 ],
118 },
119 TableContract {
120 name: "code_imports",
121 required_columns: &["id", "project_id", "source_file", "target_module"],
122 },
123 TableContract {
124 name: "code_calls",
125 required_columns: &[
126 "id",
127 "project_id",
128 "caller_symbol_id",
129 "callee_symbol_id",
130 "callee_name",
131 "callee_target_kind",
132 "callee_external_module",
133 "file_path",
134 "line",
135 ],
136 },
137];
138
139const INDEX_CONTRACTS: &[IndexContract] = &[
140 IndexContract {
141 name: "idx_cif_project",
142 table: "code_indexed_files",
143 method: "btree",
144 },
145 IndexContract {
146 name: "idx_cif_graph_synced",
147 table: "code_indexed_files",
148 method: "btree",
149 },
150 IndexContract {
151 name: "idx_cif_vectors_synced",
152 table: "code_indexed_files",
153 method: "btree",
154 },
155 IndexContract {
156 name: "idx_cs_project",
157 table: "code_symbols",
158 method: "btree",
159 },
160 IndexContract {
161 name: "idx_cs_file",
162 table: "code_symbols",
163 method: "btree",
164 },
165 IndexContract {
166 name: "idx_cs_name",
167 table: "code_symbols",
168 method: "btree",
169 },
170 IndexContract {
171 name: "idx_cs_qualified",
172 table: "code_symbols",
173 method: "btree",
174 },
175 IndexContract {
176 name: "idx_cs_kind",
177 table: "code_symbols",
178 method: "btree",
179 },
180 IndexContract {
181 name: "idx_cs_parent",
182 table: "code_symbols",
183 method: "btree",
184 },
185 IndexContract {
186 name: "idx_ccc_project",
187 table: "code_content_chunks",
188 method: "btree",
189 },
190 IndexContract {
191 name: "idx_ccc_file",
192 table: "code_content_chunks",
193 method: "btree",
194 },
195 IndexContract {
196 name: "idx_ci_file",
197 table: "code_imports",
198 method: "btree",
199 },
200 IndexContract {
201 name: "idx_cc_file",
202 table: "code_calls",
203 method: "btree",
204 },
205 IndexContract {
206 name: "idx_cc_caller",
207 table: "code_calls",
208 method: "btree",
209 },
210 IndexContract {
211 name: "idx_cc_target",
212 table: "code_calls",
213 method: "btree",
214 },
215 IndexContract {
216 name: "code_symbols_search_bm25",
217 table: "code_symbols",
218 method: "bm25",
219 },
220 IndexContract {
221 name: "code_content_search_bm25",
222 table: "code_content_chunks",
223 method: "bm25",
224 },
225];
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct StandaloneSetupRequest {
229 pub standalone: bool,
230 pub database_url: Option<String>,
231 pub no_services: bool,
232 pub overwrite_code_index: bool,
233 pub schema: String,
234 pub embedding_provider: Option<String>,
235 pub embedding_api_base: Option<String>,
236 pub embedding_model: Option<String>,
237 pub embedding_vector_dim: Option<usize>,
238 pub embedding_api_key_env: Option<String>,
239 pub falkordb_host: Option<String>,
240 pub falkordb_port: Option<u16>,
241 pub falkordb_password: Option<String>,
242 pub qdrant_url: Option<String>,
243}
244
245impl StandaloneSetupRequest {
246 pub fn new(standalone: bool, database_url: Option<String>, schema: Option<String>) -> Self {
247 Self {
248 standalone,
249 database_url,
250 no_services: false,
251 overwrite_code_index: false,
252 schema: schema.unwrap_or_else(|| DEFAULT_SCHEMA.to_string()),
253 embedding_provider: None,
254 embedding_api_base: None,
255 embedding_model: None,
256 embedding_vector_dim: None,
257 embedding_api_key_env: None,
258 falkordb_host: None,
259 falkordb_port: None,
260 falkordb_password: None,
261 qdrant_url: None,
262 }
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct StandaloneServicesStatus {
268 pub provisioned: bool,
269 pub compose_file: Option<String>,
270 pub health_checks: Vec<String>,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct StandaloneEmbeddingStatus {
275 pub provider: String,
276 pub api_base: String,
277 pub model: String,
278 pub vector_dim: usize,
279 pub api_key_env: Option<String>,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct StandaloneSetupStatus {
284 pub namespace: String,
285 pub schema: String,
286 pub created: Vec<String>,
287 pub skipped: Vec<String>,
288 pub failed: Vec<(String, String)>,
289 pub config_file: Option<String>,
290 pub services: Option<StandaloneServicesStatus>,
291 pub embedding: Option<StandaloneEmbeddingStatus>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct GcodeStandaloneSetup {
296 schema: String,
297}
298
299impl GcodeStandaloneSetup {
300 pub fn new(schema: impl Into<String>) -> Self {
301 Self {
302 schema: schema.into(),
303 }
304 }
305
306 pub fn schema(&self) -> &str {
307 &self.schema
308 }
309
310 fn object(&self, name: &str, sql: String) -> OwnedObject {
311 let object_name = name.to_string();
312 OwnedObject {
313 name: object_name.clone(),
314 store: StoreKind::Postgres,
315 creator: Box::new(move |ctx| execute_postgres_ddl(ctx, &object_name, &sql)),
316 }
317 }
318
319 fn qualified(&self, relation: &str) -> Result<String, SetupError> {
320 Ok(format!(
321 "{}.{}",
322 quote_identifier(&self.schema, "schema")?,
323 quote_identifier(relation, "relation")?
324 ))
325 }
326}
327
328impl StandaloneSetup for GcodeStandaloneSetup {
329 fn namespace(&self) -> &str {
330 NAMESPACE
331 }
332
333 fn owned_objects(&self) -> Vec<OwnedObject> {
334 let code_indexed_projects = match self.qualified("code_indexed_projects") {
335 Ok(name) => name,
336 Err(err) => return vec![invalid_object("code_indexed_projects table", err)],
337 };
338 let code_indexed_files = match self.qualified("code_indexed_files") {
339 Ok(name) => name,
340 Err(err) => return vec![invalid_object("code_indexed_files table", err)],
341 };
342 let code_symbols = match self.qualified("code_symbols") {
343 Ok(name) => name,
344 Err(err) => return vec![invalid_object("code_symbols table", err)],
345 };
346 let code_content_chunks = match self.qualified("code_content_chunks") {
347 Ok(name) => name,
348 Err(err) => return vec![invalid_object("code_content_chunks table", err)],
349 };
350 let code_imports = match self.qualified("code_imports") {
351 Ok(name) => name,
352 Err(err) => return vec![invalid_object("code_imports table", err)],
353 };
354 let code_calls = match self.qualified("code_calls") {
355 Ok(name) => name,
356 Err(err) => return vec![invalid_object("code_calls table", err)],
357 };
358
359 vec![
360 self.object(
361 "pg_search extension",
362 "CREATE EXTENSION IF NOT EXISTS pg_search;".to_string(),
363 ),
364 self.object(
365 "code_indexed_projects table",
366 format!(
367 "CREATE TABLE IF NOT EXISTS {code_indexed_projects} (
368 id TEXT PRIMARY KEY,
369 root_path TEXT NOT NULL,
370 total_files INTEGER NOT NULL DEFAULT 0,
371 total_symbols INTEGER NOT NULL DEFAULT 0,
372 last_indexed_at TIMESTAMPTZ,
373 index_duration_ms INTEGER,
374 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
375 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
376 );"
377 ),
378 ),
379 self.object(
380 "code_indexed_files table",
381 format!(
382 "CREATE TABLE IF NOT EXISTS {code_indexed_files} (
383 id TEXT PRIMARY KEY,
384 project_id TEXT NOT NULL,
385 file_path TEXT NOT NULL,
386 language TEXT NOT NULL,
387 content_hash TEXT NOT NULL,
388 symbol_count INTEGER NOT NULL DEFAULT 0,
389 byte_size INTEGER NOT NULL DEFAULT 0,
390 graph_synced BOOLEAN NOT NULL DEFAULT FALSE,
391 vectors_synced BOOLEAN NOT NULL DEFAULT FALSE,
392 graph_sync_attempted_at TIMESTAMPTZ,
393 indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
394 UNIQUE (project_id, file_path)
395 );"
396 ),
397 ),
398 self.object(
399 "idx_cif_project index",
400 format!(
401 "CREATE INDEX IF NOT EXISTS idx_cif_project
402 ON {code_indexed_files}(project_id);"
403 ),
404 ),
405 self.object(
406 "idx_cif_graph_synced index",
407 format!(
408 "CREATE INDEX IF NOT EXISTS idx_cif_graph_synced
409 ON {code_indexed_files}(project_id, graph_synced);"
410 ),
411 ),
412 self.object(
413 "idx_cif_vectors_synced index",
414 format!(
415 "CREATE INDEX IF NOT EXISTS idx_cif_vectors_synced
416 ON {code_indexed_files}(project_id, vectors_synced);"
417 ),
418 ),
419 self.object(
420 "code_symbols table",
421 format!(
422 "CREATE TABLE IF NOT EXISTS {code_symbols} (
423 id TEXT PRIMARY KEY,
424 project_id TEXT NOT NULL,
425 file_path TEXT NOT NULL,
426 name TEXT NOT NULL,
427 qualified_name TEXT NOT NULL,
428 kind TEXT NOT NULL,
429 language TEXT NOT NULL,
430 byte_start INTEGER NOT NULL,
431 byte_end INTEGER NOT NULL,
432 line_start INTEGER NOT NULL,
433 line_end INTEGER NOT NULL,
434 signature TEXT,
435 docstring TEXT,
436 parent_symbol_id TEXT,
437 content_hash TEXT NOT NULL,
438 summary TEXT,
439 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
440 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
441 );"
442 ),
443 ),
444 self.object(
445 "idx_cs_project index",
446 format!("CREATE INDEX IF NOT EXISTS idx_cs_project ON {code_symbols}(project_id);"),
447 ),
448 self.object(
449 "idx_cs_file index",
450 format!(
451 "CREATE INDEX IF NOT EXISTS idx_cs_file
452 ON {code_symbols}(project_id, file_path);"
453 ),
454 ),
455 self.object(
456 "idx_cs_name index",
457 format!("CREATE INDEX IF NOT EXISTS idx_cs_name ON {code_symbols}(name);"),
458 ),
459 self.object(
460 "idx_cs_qualified index",
461 format!(
462 "CREATE INDEX IF NOT EXISTS idx_cs_qualified
463 ON {code_symbols}(qualified_name);"
464 ),
465 ),
466 self.object(
467 "idx_cs_kind index",
468 format!("CREATE INDEX IF NOT EXISTS idx_cs_kind ON {code_symbols}(kind);"),
469 ),
470 self.object(
471 "idx_cs_parent index",
472 format!(
473 "CREATE INDEX IF NOT EXISTS idx_cs_parent
474 ON {code_symbols}(parent_symbol_id);"
475 ),
476 ),
477 self.object(
478 "code_content_chunks table",
479 format!(
480 "CREATE TABLE IF NOT EXISTS {code_content_chunks} (
481 id TEXT PRIMARY KEY,
482 project_id TEXT NOT NULL,
483 file_path TEXT NOT NULL,
484 chunk_index INTEGER NOT NULL,
485 line_start INTEGER NOT NULL,
486 line_end INTEGER NOT NULL,
487 content TEXT NOT NULL,
488 language TEXT,
489 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
490 UNIQUE (project_id, file_path, chunk_index)
491 );"
492 ),
493 ),
494 self.object(
495 "idx_ccc_project index",
496 format!(
497 "CREATE INDEX IF NOT EXISTS idx_ccc_project
498 ON {code_content_chunks}(project_id);"
499 ),
500 ),
501 self.object(
502 "idx_ccc_file index",
503 format!(
504 "CREATE INDEX IF NOT EXISTS idx_ccc_file
505 ON {code_content_chunks}(project_id, file_path);"
506 ),
507 ),
508 self.object(
509 "code_imports table",
510 format!(
511 "CREATE TABLE IF NOT EXISTS {code_imports} (
512 id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
513 project_id TEXT NOT NULL,
514 source_file TEXT NOT NULL,
515 target_module TEXT NOT NULL,
516 UNIQUE (project_id, source_file, target_module)
517 );"
518 ),
519 ),
520 self.object(
521 "idx_ci_file index",
522 format!(
523 "CREATE INDEX IF NOT EXISTS idx_ci_file
524 ON {code_imports}(project_id, source_file);"
525 ),
526 ),
527 self.object(
528 "code_calls table",
529 format!(
530 "CREATE TABLE IF NOT EXISTS {code_calls} (
531 id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
532 project_id TEXT NOT NULL,
533 caller_symbol_id TEXT NOT NULL,
534 callee_symbol_id TEXT NOT NULL DEFAULT '',
535 callee_name TEXT NOT NULL,
536 callee_target_kind TEXT NOT NULL DEFAULT 'unresolved',
537 callee_external_module TEXT NOT NULL DEFAULT '',
538 file_path TEXT NOT NULL,
539 line INTEGER NOT NULL DEFAULT 0,
540 UNIQUE (
541 project_id, caller_symbol_id, callee_symbol_id, callee_name,
542 callee_target_kind, callee_external_module, file_path, line
543 )
544 );"
545 ),
546 ),
547 self.object(
548 "idx_cc_file index",
549 format!(
550 "CREATE INDEX IF NOT EXISTS idx_cc_file
551 ON {code_calls}(project_id, file_path);"
552 ),
553 ),
554 self.object(
555 "idx_cc_caller index",
556 format!(
557 "CREATE INDEX IF NOT EXISTS idx_cc_caller
558 ON {code_calls}(project_id, caller_symbol_id);"
559 ),
560 ),
561 self.object(
562 "idx_cc_target index",
563 format!(
564 "CREATE INDEX IF NOT EXISTS idx_cc_target
565 ON {code_calls}(project_id, callee_target_kind, callee_symbol_id, callee_name);"
566 ),
567 ),
568 self.object(
569 "code_symbols_search_bm25 index",
570 format!(
571 "CREATE INDEX IF NOT EXISTS code_symbols_search_bm25
572 ON {code_symbols}
573 USING bm25 (id, name, qualified_name, signature, docstring, summary)
574 WITH (key_field = 'id');"
575 ),
576 ),
577 self.object(
578 "code_content_search_bm25 index",
579 format!(
580 "CREATE INDEX IF NOT EXISTS code_content_search_bm25
581 ON {code_content_chunks}
582 USING bm25 (id, content)
583 WITH (key_field = 'id');"
584 ),
585 ),
586 ]
587 }
588
589 fn create(&self, ctx: &mut SetupContext<'_>) -> Result<SetupReport, SetupError> {
590 let mut report = SetupReport::default();
591 for mut object in self.owned_objects() {
592 match (object.creator)(ctx) {
593 Ok(()) => report.created.push(object.name),
594 Err(err) => {
595 report.failed.push((object.name, err.to_string()));
596 break;
597 }
598 }
599 }
600 Ok(report)
601 }
602}
603
604pub fn run_standalone_setup(
605 request: &StandaloneSetupRequest,
606 client: &mut Client,
607) -> Result<StandaloneSetupStatus, SetupError> {
608 validate_standalone_request(request)?;
609
610 let setup = GcodeStandaloneSetup::new(request.schema.clone());
611 begin_postgres_transaction(client)?;
612 let mut reset_report = SetupReport::default();
613 if request.overwrite_code_index {
614 if let Err(err) = reset_postgres_code_index(client, setup.schema()) {
615 reset_report
616 .failed
617 .push(("code-index overwrite reset".to_string(), err.to_string()));
618 rollback_postgres_transaction(client, "code-index overwrite reset rollback")?;
619 return Ok(standalone_setup_status(&setup, reset_report));
620 }
621 } else {
622 if let Err(err) = ensure_postgres_code_index_compatible(client, setup.schema()) {
623 rollback_postgres_transaction(client, "code-index preflight rollback")?;
624 return Err(err);
625 }
626 }
627
628 let report = {
629 let mut ctx = SetupContext {
630 pg: Some(client),
631 falkor_config: None,
632 qdrant_config: None,
633 non_interactive: true,
634 };
635 setup.create(&mut ctx)?
636 };
637 if report.failed.is_empty() {
638 commit_postgres_transaction(client)?;
639 } else {
640 rollback_postgres_transaction(client, "standalone setup rollback")?;
641 }
642
643 Ok(standalone_setup_status(&setup, report))
644}
645
646fn standalone_setup_status(
647 setup: &GcodeStandaloneSetup,
648 report: SetupReport,
649) -> StandaloneSetupStatus {
650 StandaloneSetupStatus {
651 namespace: setup.namespace().to_string(),
652 schema: setup.schema().to_string(),
653 created: report.created,
654 skipped: report.skipped,
655 failed: report.failed,
656 config_file: None,
657 services: None,
658 embedding: None,
659 }
660}
661
662fn begin_postgres_transaction(client: &mut Client) -> Result<(), SetupError> {
663 client
664 .batch_execute("BEGIN")
665 .map_err(|err| SetupError::CreationFailed {
666 object: "standalone setup transaction".to_string(),
667 message: err.to_string(),
668 })
669}
670
671fn commit_postgres_transaction(client: &mut Client) -> Result<(), SetupError> {
672 client
673 .batch_execute("COMMIT")
674 .map_err(|err| SetupError::CreationFailed {
675 object: "standalone setup commit".to_string(),
676 message: err.to_string(),
677 })
678}
679
680fn rollback_postgres_transaction(client: &mut Client, object: &str) -> Result<(), SetupError> {
681 client
682 .batch_execute("ROLLBACK")
683 .map_err(|err| SetupError::CreationFailed {
684 object: object.to_string(),
685 message: err.to_string(),
686 })
687}
688
689pub(crate) fn ensure_postgres_code_index_compatible(
690 client: &mut impl GenericClient,
691 schema: &str,
692) -> Result<(), SetupError> {
693 let issues = incompatible_postgres_code_index_relations(client, schema)?;
694 if issues.is_empty() {
695 return Ok(());
696 }
697
698 Err(SetupError::CreationFailed {
699 object: "code-index preflight".to_string(),
700 message: format!(
701 "existing code-index PostgreSQL state is incompatible: {}. {OVERWRITE_GUIDANCE}",
702 issues.join("; ")
703 ),
704 })
705}
706
707pub(crate) fn reset_postgres_code_index(
708 client: &mut impl GenericClient,
709 schema: &str,
710) -> Result<(), SetupError> {
711 let sql = postgres_overwrite_reset_sql(schema)?;
712 client
713 .batch_execute(&sql)
714 .map_err(|err| SetupError::CreationFailed {
715 object: "code-index overwrite reset".to_string(),
716 message: err.to_string(),
717 })
718}
719
720pub(crate) fn postgres_overwrite_reset_sql(schema: &str) -> Result<String, SetupError> {
721 let mut statements = Vec::new();
722 for index in CODE_INDEX_INDEXES {
723 statements.push(format!(
724 "DROP INDEX IF EXISTS {};",
725 qualified_relation(schema, index, "index")?
726 ));
727 }
728 for table in CODE_INDEX_TABLES.iter().rev() {
729 statements.push(format!(
730 "DROP TABLE IF EXISTS {};",
731 qualified_relation(schema, table, "table")?
732 ));
733 }
734 Ok(statements.join("\n"))
735}
736
737fn incompatible_postgres_code_index_relations(
738 client: &mut impl GenericClient,
739 schema: &str,
740) -> Result<Vec<String>, SetupError> {
741 let mut issues = Vec::new();
742 for contract in TABLE_CONTRACTS {
743 inspect_table_contract(client, schema, contract, &mut issues)?;
744 }
745 for contract in INDEX_CONTRACTS {
746 inspect_index_contract(client, schema, contract, &mut issues)?;
747 }
748 Ok(issues)
749}
750
751fn inspect_table_contract(
752 client: &mut impl GenericClient,
753 schema: &str,
754 contract: &TableContract,
755 issues: &mut Vec<String>,
756) -> Result<(), SetupError> {
757 let Some(kind) = relation_kind(client, schema, contract.name)? else {
758 return Ok(());
759 };
760 if kind != "r" {
761 issues.push(format!(
762 "{} exists but is not an ordinary table",
763 contract.name
764 ));
765 return Ok(());
766 }
767
768 let existing = table_columns(client, schema, contract.name)?;
769 let missing = contract
770 .required_columns
771 .iter()
772 .filter(|column| !existing.contains::<str>(column))
773 .copied()
774 .collect::<Vec<_>>();
775 if !missing.is_empty() {
776 issues.push(format!(
777 "{} is missing column(s): {}",
778 contract.name,
779 missing.join(", ")
780 ));
781 }
782 Ok(())
783}
784
785fn inspect_index_contract(
786 client: &mut impl GenericClient,
787 schema: &str,
788 contract: &IndexContract,
789 issues: &mut Vec<String>,
790) -> Result<(), SetupError> {
791 let Some(index) = index_info(client, schema, contract.name)? else {
792 return Ok(());
793 };
794
795 if index.relkind != "i" && index.relkind != "I" {
796 issues.push(format!("{} exists but is not an index", contract.name));
797 return Ok(());
798 }
799 if index.table_name.as_deref() != Some(contract.table) {
800 issues.push(format!(
801 "{} is attached to {}, expected {}",
802 contract.name,
803 index.table_name.as_deref().unwrap_or("<unknown>"),
804 contract.table
805 ));
806 }
807 if index.method.as_deref() != Some(contract.method) {
808 issues.push(format!(
809 "{} uses access method {}, expected {}",
810 contract.name,
811 index.method.as_deref().unwrap_or("<unknown>"),
812 contract.method
813 ));
814 }
815 Ok(())
816}
817
818fn relation_kind(
819 client: &mut impl GenericClient,
820 schema: &str,
821 relation: &str,
822) -> Result<Option<String>, SetupError> {
823 let row = client
824 .query_opt(
825 "SELECT c.relkind::TEXT
826 FROM pg_class c
827 JOIN pg_namespace n ON n.oid = c.relnamespace
828 WHERE n.nspname = $1 AND c.relname = $2",
829 &[&schema, &relation],
830 )
831 .map_err(|err| SetupError::CreationFailed {
832 object: format!("{relation} preflight"),
833 message: err.to_string(),
834 })?;
835 Ok(row.map(|row| row.get(0)))
836}
837
838fn table_columns(
839 client: &mut impl GenericClient,
840 schema: &str,
841 table: &str,
842) -> Result<HashSet<String>, SetupError> {
843 let rows = client
844 .query(
845 "SELECT a.attname
846 FROM pg_attribute a
847 JOIN pg_class c ON c.oid = a.attrelid
848 JOIN pg_namespace n ON n.oid = c.relnamespace
849 WHERE n.nspname = $1
850 AND c.relname = $2
851 AND a.attnum > 0
852 AND NOT a.attisdropped",
853 &[&schema, &table],
854 )
855 .map_err(|err| SetupError::CreationFailed {
856 object: format!("{table} preflight"),
857 message: err.to_string(),
858 })?;
859 Ok(rows.into_iter().map(|row| row.get(0)).collect())
860}
861
862struct ExistingIndexInfo {
863 relkind: String,
864 table_name: Option<String>,
865 method: Option<String>,
866}
867
868fn index_info(
869 client: &mut impl GenericClient,
870 schema: &str,
871 index: &str,
872) -> Result<Option<ExistingIndexInfo>, SetupError> {
873 let row = client
874 .query_opt(
875 "SELECT c.relkind::TEXT,
876 table_class.relname::TEXT AS table_name,
877 am.amname::TEXT AS method
878 FROM pg_class c
879 JOIN pg_namespace n ON n.oid = c.relnamespace
880 LEFT JOIN pg_index idx ON idx.indexrelid = c.oid
881 LEFT JOIN pg_class table_class ON table_class.oid = idx.indrelid
882 LEFT JOIN pg_am am ON am.oid = c.relam
883 WHERE n.nspname = $1 AND c.relname = $2",
884 &[&schema, &index],
885 )
886 .map_err(|err| SetupError::CreationFailed {
887 object: format!("{index} preflight"),
888 message: err.to_string(),
889 })?;
890
891 Ok(row.map(|row| ExistingIndexInfo {
892 relkind: row.get(0),
893 table_name: row.get(1),
894 method: row.get(2),
895 }))
896}
897
898pub fn validate_standalone_request(request: &StandaloneSetupRequest) -> Result<(), SetupError> {
899 if !request.standalone {
900 return Err(SetupError::AttachedModeRefused);
901 }
902 if request.schema != DEFAULT_SCHEMA {
903 return Err(SetupError::CreationFailed {
904 object: "schema".to_string(),
905 message: "standalone code-index schema must be `public` for daemon adoption"
906 .to_string(),
907 });
908 }
909 Ok(())
910}
911
912fn qualified_relation(schema: &str, relation: &str, label: &str) -> Result<String, SetupError> {
913 Ok(format!(
914 "{}.{}",
915 quote_identifier(schema, "schema")?,
916 quote_identifier(relation, label)?
917 ))
918}
919
920fn execute_postgres_ddl(
921 ctx: &mut SetupContext<'_>,
922 object: &str,
923 sql: &str,
924) -> Result<(), SetupError> {
925 let Some(pg) = ctx.pg.as_deref_mut() else {
926 return Err(SetupError::ConnectionFailed {
927 store: "postgres".to_string(),
928 message: "PostgreSQL connection was not supplied to setup context".to_string(),
929 });
930 };
931
932 pg.batch_execute(sql)
933 .map_err(|err| SetupError::CreationFailed {
934 object: object.to_string(),
935 message: err.to_string(),
936 })
937}
938
939fn invalid_object(name: &str, err: SetupError) -> OwnedObject {
940 let message = err.to_string();
941 let object_name = name.to_string();
942 OwnedObject {
943 name: object_name.clone(),
944 store: StoreKind::Postgres,
945 creator: Box::new(move |_| {
946 Err(SetupError::CreationFailed {
947 object: object_name.clone(),
948 message: message.clone(),
949 })
950 }),
951 }
952}
953
954fn quote_identifier(value: &str, label: &str) -> Result<String, SetupError> {
955 let trimmed = value.trim();
956 if trimmed.is_empty() {
957 return Err(SetupError::CreationFailed {
958 object: label.to_string(),
959 message: format!("{label} identifier must not be empty"),
960 });
961 }
962 if trimmed.contains('\0') {
963 return Err(SetupError::CreationFailed {
964 object: label.to_string(),
965 message: format!("{label} identifier must not contain NUL bytes"),
966 });
967 }
968 Ok(format!("\"{}\"", trimmed.replace('"', "\"\"")))
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974 use gobby_core::setup::{StandaloneSetup, StoreKind};
975 use postgres::NoTls;
976
977 #[test]
978 fn standalone_setup_declares_public_daemon_code_index_subset() {
979 let setup = GcodeStandaloneSetup::new("public");
980 assert_eq!(setup.namespace(), "gcode");
981 assert_eq!(setup.schema(), "public");
982
983 let object_names: Vec<String> = setup
984 .owned_objects()
985 .into_iter()
986 .map(|object| object.name)
987 .collect();
988
989 assert!(
990 object_names
991 .iter()
992 .any(|name| name.contains("indexed_files"))
993 );
994 assert!(object_names.iter().any(|name| name.contains("symbols")));
995 assert!(
996 object_names
997 .iter()
998 .any(|name| name.contains("content_chunks"))
999 );
1000 assert!(object_names.iter().any(|name| name.contains("idx_cif")));
1001 assert!(object_names.iter().any(|name| name.contains("bm25")));
1002
1003 let forbidden = [
1004 "config_store",
1005 "schema_migrations",
1006 "secrets",
1007 ".gobby/project.json",
1008 "project_json",
1009 "code_graph_sync_state",
1010 "code_vector_sync_state",
1011 ];
1012 for name in object_names {
1013 for forbidden_name in forbidden {
1014 assert!(
1015 !name.contains(forbidden_name),
1016 "standalone setup declared forbidden object {name}"
1017 );
1018 }
1019 }
1020 }
1021
1022 #[test]
1023 fn standalone_setup_uses_gobby_core_contract() {
1024 fn assert_standalone_setup<T: StandaloneSetup>() {}
1025 assert_standalone_setup::<GcodeStandaloneSetup>();
1026
1027 let setup = GcodeStandaloneSetup::new("public");
1028 let objects = setup.owned_objects();
1029 assert!(
1030 objects
1031 .iter()
1032 .all(|object| object.store == StoreKind::Postgres)
1033 );
1034 assert!(
1035 objects
1036 .iter()
1037 .any(|object| object.name == "code_symbols table")
1038 );
1039 assert!(
1040 objects
1041 .iter()
1042 .any(|object| object.name == "code_symbols_search_bm25 index")
1043 );
1044 assert!(
1045 objects
1046 .iter()
1047 .any(|object| object.name == "pg_search extension")
1048 );
1049 }
1050
1051 #[test]
1052 fn standalone_setup_rejects_non_public_schema() {
1053 let request = StandaloneSetupRequest::new(
1054 true,
1055 Some("postgresql://localhost/gcode".to_string()),
1056 Some("gcode_ci".to_string()),
1057 );
1058 let err = validate_standalone_request(&request).expect_err("non-public schema fails");
1059 assert!(err.to_string().contains("public"));
1060 }
1061
1062 #[test]
1063 fn overwrite_reset_sql_is_allowlisted() {
1064 let sql = postgres_overwrite_reset_sql("public").expect("reset SQL");
1065
1066 for table in CODE_INDEX_TABLES {
1067 assert!(
1068 sql.contains(&format!("DROP TABLE IF EXISTS \"public\".\"{table}\";")),
1069 "{sql}"
1070 );
1071 }
1072 for index in CODE_INDEX_INDEXES {
1073 assert!(
1074 sql.contains(&format!("DROP INDEX IF EXISTS \"public\".\"{index}\";")),
1075 "{sql}"
1076 );
1077 }
1078
1079 for forbidden in [
1080 "config_store",
1081 "schema_migrations",
1082 "secrets",
1083 "tasks",
1084 "sessions",
1085 "memory",
1086 ".gobby/project.json",
1087 ] {
1088 assert!(!sql.contains(forbidden), "{sql}");
1089 }
1090 assert!(!sql.contains("CASCADE"), "{sql}");
1091 assert!(!sql.contains("DROP DATABASE"), "{sql}");
1092 assert!(!sql.contains("DROP SCHEMA"), "{sql}");
1093 }
1094
1095 #[test]
1096 fn overwrite_guidance_names_flag() {
1097 let request = StandaloneSetupRequest::new(true, None, None);
1098 assert!(!request.overwrite_code_index);
1099 assert!(OVERWRITE_GUIDANCE.contains("--overwrite-code-index"));
1100 }
1101
1102 #[test]
1103 #[serial_test::serial]
1104 fn overwrite_recreates_incompatible_code_index_and_preserves_sentinel_table() {
1105 let Ok(database_url) = std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL") else {
1106 return;
1107 };
1108 let mut client =
1109 Client::connect(&database_url, NoTls).expect("connect test PostgreSQL hub");
1110 cleanup_code_index_relations(&mut client);
1111 client
1112 .batch_execute(
1113 "CREATE TABLE public.code_symbols (id TEXT PRIMARY KEY);
1114 CREATE TABLE IF NOT EXISTS public.gobby_owned_sentinel (
1115 key TEXT PRIMARY KEY,
1116 value TEXT NOT NULL
1117 );
1118 INSERT INTO public.gobby_owned_sentinel (key, value)
1119 VALUES ('gcode-overwrite-sentinel', 'keep-me')
1120 ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;",
1121 )
1122 .expect("seed incompatible code index and sentinel");
1123
1124 let request = StandaloneSetupRequest::new(true, Some(database_url.clone()), None);
1125 let err = run_standalone_setup(&request, &mut client)
1126 .expect_err("incompatible setup fails without overwrite");
1127 assert!(err.to_string().contains("--overwrite-code-index"));
1128
1129 let mut overwrite = StandaloneSetupRequest::new(true, Some(database_url), None);
1130 overwrite.overwrite_code_index = true;
1131 run_standalone_setup(&overwrite, &mut client).expect("overwrite setup succeeds");
1132
1133 let has_project_id: bool = client
1134 .query_one(
1135 "SELECT EXISTS(
1136 SELECT 1
1137 FROM pg_attribute
1138 WHERE attrelid = 'public.code_symbols'::regclass
1139 AND attname = 'project_id'
1140 AND attnum > 0
1141 AND NOT attisdropped
1142 )",
1143 &[],
1144 )
1145 .expect("check recreated code_symbols")
1146 .get(0);
1147 assert!(has_project_id);
1148
1149 let sentinel: String = client
1150 .query_one(
1151 "SELECT value FROM public.gobby_owned_sentinel WHERE key = 'gcode-overwrite-sentinel'",
1152 &[],
1153 )
1154 .expect("read sentinel")
1155 .get(0);
1156 assert_eq!(sentinel, "keep-me");
1157
1158 cleanup_code_index_relations(&mut client);
1159 client
1160 .batch_execute(
1161 "DELETE FROM public.gobby_owned_sentinel WHERE key = 'gcode-overwrite-sentinel';
1162 DROP TABLE IF EXISTS public.gobby_owned_sentinel;",
1163 )
1164 .expect("cleanup sentinel");
1165 }
1166
1167 fn cleanup_code_index_relations(client: &mut Client) {
1168 let sql = postgres_overwrite_reset_sql("public").expect("reset SQL");
1169 client
1170 .batch_execute(&sql)
1171 .expect("cleanup code index objects");
1172 }
1173}