1use gobby_core::setup::{
2 OwnedObject, SetupContext, SetupError, SetupReport, StandaloneSetup, StoreKind,
3};
4use postgres::Client;
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 return Err(err);
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 if request.overwrite_code_index {
612 reset_postgres_code_index(client, setup.schema())?;
613 } else {
614 ensure_postgres_code_index_compatible(client, setup.schema())?;
615 }
616
617 let mut ctx = SetupContext {
618 pg: Some(client),
619 falkor_config: None,
620 qdrant_config: None,
621 non_interactive: true,
622 };
623 let report = setup.create(&mut ctx)?;
624
625 Ok(StandaloneSetupStatus {
626 namespace: setup.namespace().to_string(),
627 schema: setup.schema().to_string(),
628 created: report.created,
629 skipped: report.skipped,
630 failed: report.failed,
631 config_file: None,
632 services: None,
633 embedding: None,
634 })
635}
636
637pub(crate) fn ensure_postgres_code_index_compatible(
638 client: &mut Client,
639 schema: &str,
640) -> Result<(), SetupError> {
641 let issues = incompatible_postgres_code_index_relations(client, schema)?;
642 if issues.is_empty() {
643 return Ok(());
644 }
645
646 Err(SetupError::CreationFailed {
647 object: "code-index preflight".to_string(),
648 message: format!(
649 "existing code-index PostgreSQL state is incompatible: {}. {OVERWRITE_GUIDANCE}",
650 issues.join("; ")
651 ),
652 })
653}
654
655pub(crate) fn reset_postgres_code_index(
656 client: &mut Client,
657 schema: &str,
658) -> Result<(), SetupError> {
659 let sql = postgres_overwrite_reset_sql(schema)?;
660 client
661 .batch_execute(&sql)
662 .map_err(|err| SetupError::CreationFailed {
663 object: "code-index overwrite reset".to_string(),
664 message: err.to_string(),
665 })
666}
667
668pub(crate) fn postgres_overwrite_reset_sql(schema: &str) -> Result<String, SetupError> {
669 let mut statements = Vec::new();
670 for index in CODE_INDEX_INDEXES {
671 statements.push(format!(
672 "DROP INDEX IF EXISTS {};",
673 qualified_relation(schema, index, "index")?
674 ));
675 }
676 for table in CODE_INDEX_TABLES.iter().rev() {
677 statements.push(format!(
678 "DROP TABLE IF EXISTS {};",
679 qualified_relation(schema, table, "table")?
680 ));
681 }
682 Ok(statements.join("\n"))
683}
684
685fn incompatible_postgres_code_index_relations(
686 client: &mut Client,
687 schema: &str,
688) -> Result<Vec<String>, SetupError> {
689 let mut issues = Vec::new();
690 for contract in TABLE_CONTRACTS {
691 inspect_table_contract(client, schema, contract, &mut issues)?;
692 }
693 for contract in INDEX_CONTRACTS {
694 inspect_index_contract(client, schema, contract, &mut issues)?;
695 }
696 Ok(issues)
697}
698
699fn inspect_table_contract(
700 client: &mut Client,
701 schema: &str,
702 contract: &TableContract,
703 issues: &mut Vec<String>,
704) -> Result<(), SetupError> {
705 let Some(kind) = relation_kind(client, schema, contract.name)? else {
706 return Ok(());
707 };
708 if kind != "r" {
709 issues.push(format!(
710 "{} exists but is not an ordinary table",
711 contract.name
712 ));
713 return Ok(());
714 }
715
716 let existing = table_columns(client, schema, contract.name)?;
717 let missing = contract
718 .required_columns
719 .iter()
720 .filter(|column| !existing.contains::<str>(column))
721 .copied()
722 .collect::<Vec<_>>();
723 if !missing.is_empty() {
724 issues.push(format!(
725 "{} is missing column(s): {}",
726 contract.name,
727 missing.join(", ")
728 ));
729 }
730 Ok(())
731}
732
733fn inspect_index_contract(
734 client: &mut Client,
735 schema: &str,
736 contract: &IndexContract,
737 issues: &mut Vec<String>,
738) -> Result<(), SetupError> {
739 let Some(index) = index_info(client, schema, contract.name)? else {
740 return Ok(());
741 };
742
743 if index.relkind != "i" && index.relkind != "I" {
744 issues.push(format!("{} exists but is not an index", contract.name));
745 return Ok(());
746 }
747 if index.table_name.as_deref() != Some(contract.table) {
748 issues.push(format!(
749 "{} is attached to {}, expected {}",
750 contract.name,
751 index.table_name.as_deref().unwrap_or("<unknown>"),
752 contract.table
753 ));
754 }
755 if index.method.as_deref() != Some(contract.method) {
756 issues.push(format!(
757 "{} uses access method {}, expected {}",
758 contract.name,
759 index.method.as_deref().unwrap_or("<unknown>"),
760 contract.method
761 ));
762 }
763 Ok(())
764}
765
766fn relation_kind(
767 client: &mut Client,
768 schema: &str,
769 relation: &str,
770) -> Result<Option<String>, SetupError> {
771 let row = client
772 .query_opt(
773 "SELECT c.relkind::TEXT
774 FROM pg_class c
775 JOIN pg_namespace n ON n.oid = c.relnamespace
776 WHERE n.nspname = $1 AND c.relname = $2",
777 &[&schema, &relation],
778 )
779 .map_err(|err| SetupError::CreationFailed {
780 object: format!("{relation} preflight"),
781 message: err.to_string(),
782 })?;
783 Ok(row.map(|row| row.get(0)))
784}
785
786fn table_columns(
787 client: &mut Client,
788 schema: &str,
789 table: &str,
790) -> Result<HashSet<String>, SetupError> {
791 let rows = client
792 .query(
793 "SELECT a.attname
794 FROM pg_attribute a
795 JOIN pg_class c ON c.oid = a.attrelid
796 JOIN pg_namespace n ON n.oid = c.relnamespace
797 WHERE n.nspname = $1
798 AND c.relname = $2
799 AND a.attnum > 0
800 AND NOT a.attisdropped",
801 &[&schema, &table],
802 )
803 .map_err(|err| SetupError::CreationFailed {
804 object: format!("{table} preflight"),
805 message: err.to_string(),
806 })?;
807 Ok(rows.into_iter().map(|row| row.get(0)).collect())
808}
809
810struct ExistingIndexInfo {
811 relkind: String,
812 table_name: Option<String>,
813 method: Option<String>,
814}
815
816fn index_info(
817 client: &mut Client,
818 schema: &str,
819 index: &str,
820) -> Result<Option<ExistingIndexInfo>, SetupError> {
821 let row = client
822 .query_opt(
823 "SELECT c.relkind::TEXT,
824 table_class.relname::TEXT AS table_name,
825 am.amname::TEXT AS method
826 FROM pg_class c
827 JOIN pg_namespace n ON n.oid = c.relnamespace
828 LEFT JOIN pg_index idx ON idx.indexrelid = c.oid
829 LEFT JOIN pg_class table_class ON table_class.oid = idx.indrelid
830 LEFT JOIN pg_am am ON am.oid = c.relam
831 WHERE n.nspname = $1 AND c.relname = $2",
832 &[&schema, &index],
833 )
834 .map_err(|err| SetupError::CreationFailed {
835 object: format!("{index} preflight"),
836 message: err.to_string(),
837 })?;
838
839 Ok(row.map(|row| ExistingIndexInfo {
840 relkind: row.get(0),
841 table_name: row.get(1),
842 method: row.get(2),
843 }))
844}
845
846pub fn validate_standalone_request(request: &StandaloneSetupRequest) -> Result<(), SetupError> {
847 if !request.standalone {
848 return Err(SetupError::AttachedModeRefused);
849 }
850 if request.schema != DEFAULT_SCHEMA {
851 return Err(SetupError::CreationFailed {
852 object: "schema".to_string(),
853 message: "standalone code-index schema must be `public` for daemon adoption"
854 .to_string(),
855 });
856 }
857 Ok(())
858}
859
860fn qualified_relation(schema: &str, relation: &str, label: &str) -> Result<String, SetupError> {
861 Ok(format!(
862 "{}.{}",
863 quote_identifier(schema, "schema")?,
864 quote_identifier(relation, label)?
865 ))
866}
867
868fn execute_postgres_ddl(
869 ctx: &mut SetupContext<'_>,
870 object: &str,
871 sql: &str,
872) -> Result<(), SetupError> {
873 let Some(pg) = ctx.pg.as_deref_mut() else {
874 return Err(SetupError::ConnectionFailed {
875 store: "postgres".to_string(),
876 message: "PostgreSQL connection was not supplied to setup context".to_string(),
877 });
878 };
879
880 pg.batch_execute(sql)
881 .map_err(|err| SetupError::CreationFailed {
882 object: object.to_string(),
883 message: err.to_string(),
884 })
885}
886
887fn invalid_object(name: &str, err: SetupError) -> OwnedObject {
888 let message = err.to_string();
889 let object_name = name.to_string();
890 OwnedObject {
891 name: object_name.clone(),
892 store: StoreKind::Postgres,
893 creator: Box::new(move |_| {
894 Err(SetupError::CreationFailed {
895 object: object_name.clone(),
896 message: message.clone(),
897 })
898 }),
899 }
900}
901
902fn quote_identifier(value: &str, label: &str) -> Result<String, SetupError> {
903 let trimmed = value.trim();
904 if trimmed.is_empty() {
905 return Err(SetupError::CreationFailed {
906 object: label.to_string(),
907 message: format!("{label} identifier must not be empty"),
908 });
909 }
910 if trimmed.contains('\0') {
911 return Err(SetupError::CreationFailed {
912 object: label.to_string(),
913 message: format!("{label} identifier must not contain NUL bytes"),
914 });
915 }
916 Ok(format!("\"{}\"", trimmed.replace('"', "\"\"")))
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use gobby_core::setup::{StandaloneSetup, StoreKind};
923 use postgres::NoTls;
924
925 #[test]
926 fn standalone_setup_declares_public_daemon_code_index_subset() {
927 let setup = GcodeStandaloneSetup::new("public");
928 assert_eq!(setup.namespace(), "gcode");
929 assert_eq!(setup.schema(), "public");
930
931 let object_names: Vec<String> = setup
932 .owned_objects()
933 .into_iter()
934 .map(|object| object.name)
935 .collect();
936
937 assert!(
938 object_names
939 .iter()
940 .any(|name| name.contains("indexed_files"))
941 );
942 assert!(object_names.iter().any(|name| name.contains("symbols")));
943 assert!(
944 object_names
945 .iter()
946 .any(|name| name.contains("content_chunks"))
947 );
948 assert!(object_names.iter().any(|name| name.contains("idx_cif")));
949 assert!(object_names.iter().any(|name| name.contains("bm25")));
950
951 let forbidden = [
952 "config_store",
953 "schema_migrations",
954 "secrets",
955 ".gobby/project.json",
956 "project_json",
957 "code_graph_sync_state",
958 "code_vector_sync_state",
959 ];
960 for name in object_names {
961 for forbidden_name in forbidden {
962 assert!(
963 !name.contains(forbidden_name),
964 "standalone setup declared forbidden object {name}"
965 );
966 }
967 }
968 }
969
970 #[test]
971 fn standalone_setup_uses_gobby_core_contract() {
972 fn assert_standalone_setup<T: StandaloneSetup>() {}
973 assert_standalone_setup::<GcodeStandaloneSetup>();
974
975 let setup = GcodeStandaloneSetup::new("public");
976 let objects = setup.owned_objects();
977 assert!(
978 objects
979 .iter()
980 .all(|object| object.store == StoreKind::Postgres)
981 );
982 assert!(
983 objects
984 .iter()
985 .any(|object| object.name == "code_symbols table")
986 );
987 assert!(
988 objects
989 .iter()
990 .any(|object| object.name == "code_symbols_search_bm25 index")
991 );
992 assert!(
993 objects
994 .iter()
995 .any(|object| object.name == "pg_search extension")
996 );
997 }
998
999 #[test]
1000 fn standalone_setup_rejects_non_public_schema() {
1001 let request = StandaloneSetupRequest::new(
1002 true,
1003 Some("postgresql://localhost/gcode".to_string()),
1004 Some("gcode_ci".to_string()),
1005 );
1006 let err = validate_standalone_request(&request).expect_err("non-public schema fails");
1007 assert!(err.to_string().contains("public"));
1008 }
1009
1010 #[test]
1011 fn overwrite_reset_sql_is_allowlisted() {
1012 let sql = postgres_overwrite_reset_sql("public").expect("reset SQL");
1013
1014 for table in CODE_INDEX_TABLES {
1015 assert!(
1016 sql.contains(&format!("DROP TABLE IF EXISTS \"public\".\"{table}\";")),
1017 "{sql}"
1018 );
1019 }
1020 for index in CODE_INDEX_INDEXES {
1021 assert!(
1022 sql.contains(&format!("DROP INDEX IF EXISTS \"public\".\"{index}\";")),
1023 "{sql}"
1024 );
1025 }
1026
1027 for forbidden in [
1028 "config_store",
1029 "schema_migrations",
1030 "secrets",
1031 "tasks",
1032 "sessions",
1033 "memory",
1034 ".gobby/project.json",
1035 ] {
1036 assert!(!sql.contains(forbidden), "{sql}");
1037 }
1038 assert!(!sql.contains("CASCADE"), "{sql}");
1039 assert!(!sql.contains("DROP DATABASE"), "{sql}");
1040 assert!(!sql.contains("DROP SCHEMA"), "{sql}");
1041 }
1042
1043 #[test]
1044 fn overwrite_guidance_names_flag() {
1045 let request = StandaloneSetupRequest::new(true, None, None);
1046 assert!(!request.overwrite_code_index);
1047 assert!(OVERWRITE_GUIDANCE.contains("--overwrite-code-index"));
1048 }
1049
1050 #[test]
1051 #[serial_test::serial]
1052 fn overwrite_recreates_incompatible_code_index_and_preserves_sentinel_table() {
1053 let Ok(database_url) = std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL") else {
1054 return;
1055 };
1056 let mut client =
1057 Client::connect(&database_url, NoTls).expect("connect test PostgreSQL hub");
1058 cleanup_code_index_relations(&mut client);
1059 client
1060 .batch_execute(
1061 "CREATE TABLE public.code_symbols (id TEXT PRIMARY KEY);
1062 CREATE TABLE IF NOT EXISTS public.gobby_owned_sentinel (
1063 key TEXT PRIMARY KEY,
1064 value TEXT NOT NULL
1065 );
1066 INSERT INTO public.gobby_owned_sentinel (key, value)
1067 VALUES ('gcode-overwrite-sentinel', 'keep-me')
1068 ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;",
1069 )
1070 .expect("seed incompatible code index and sentinel");
1071
1072 let request = StandaloneSetupRequest::new(true, Some(database_url.clone()), None);
1073 let err = run_standalone_setup(&request, &mut client)
1074 .expect_err("incompatible setup fails without overwrite");
1075 assert!(err.to_string().contains("--overwrite-code-index"));
1076
1077 let mut overwrite = StandaloneSetupRequest::new(true, Some(database_url), None);
1078 overwrite.overwrite_code_index = true;
1079 run_standalone_setup(&overwrite, &mut client).expect("overwrite setup succeeds");
1080
1081 let has_project_id: bool = client
1082 .query_one(
1083 "SELECT EXISTS(
1084 SELECT 1
1085 FROM pg_attribute
1086 WHERE attrelid = 'public.code_symbols'::regclass
1087 AND attname = 'project_id'
1088 AND attnum > 0
1089 AND NOT attisdropped
1090 )",
1091 &[],
1092 )
1093 .expect("check recreated code_symbols")
1094 .get(0);
1095 assert!(has_project_id);
1096
1097 let sentinel: String = client
1098 .query_one(
1099 "SELECT value FROM public.gobby_owned_sentinel WHERE key = 'gcode-overwrite-sentinel'",
1100 &[],
1101 )
1102 .expect("read sentinel")
1103 .get(0);
1104 assert_eq!(sentinel, "keep-me");
1105
1106 cleanup_code_index_relations(&mut client);
1107 client
1108 .batch_execute(
1109 "DELETE FROM public.gobby_owned_sentinel WHERE key = 'gcode-overwrite-sentinel';
1110 DROP TABLE IF EXISTS public.gobby_owned_sentinel;",
1111 )
1112 .expect("cleanup sentinel");
1113 }
1114
1115 fn cleanup_code_index_relations(client: &mut Client) {
1116 let sql = postgres_overwrite_reset_sql("public").expect("reset SQL");
1117 client
1118 .batch_execute(&sql)
1119 .expect("cleanup code index objects");
1120 }
1121}