Skip to main content

comindly_core/
index.rs

1//! Comindly index — the versioned LanceDB store (local dir or S3).
2//!
3//! Tables: `symbols`, `edges`, `file_facts`, `search`, `enrichment`, `flows`, `style_guide`, `repo_meta`.
4//! Each overwrite creates a new Lance version (prior versions retained), so the newest manifest
5//! is the atomic "latest" pointer. Remote stores use the platform's own credentials.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::facts::FileFacts;
11use crate::model::{Edge, Symbol};
12use anyhow::{Context, Result};
13use lancedb::arrow::arrow_array::{
14    ArrayRef, BooleanArray, FixedSizeListArray, Float32Array, RecordBatch, StringArray,
15    UInt32Array, UInt64Array,
16};
17use lancedb::arrow::arrow_schema::{DataType, Field, Schema};
18use lancedb::table::AddDataMode;
19
20/// One enrichment/flow row: (symbol or entry id, generated text, associated queries).
21type TextRow = (GlobalSymbolId, String, Vec<String>);
22/// One search-table row: (symbol id, search text, embedding vector).
23type SearchRow = (GlobalSymbolId, String, Vec<f32>);
24/// A persisted (symbol id, embedding vector) pair.
25type VectorRow = (GlobalSymbolId, Vec<f32>);
26
27fn symbols_schema() -> Arc<Schema> {
28    Arc::new(Schema::new(vec![
29        Field::new("symbol_id", DataType::Utf8, false),
30        Field::new("scheme", DataType::Utf8, false),
31        Field::new("package_manager", DataType::Utf8, false),
32        Field::new("package", DataType::Utf8, false),
33        Field::new("version", DataType::Utf8, false),
34        Field::new("descriptor", DataType::Utf8, false),
35        Field::new("name", DataType::Utf8, false),
36        Field::new("kind", DataType::Utf8, false),
37        Field::new("language", DataType::Utf8, false),
38        Field::new("repo", DataType::Utf8, false),
39        Field::new("file_path", DataType::Utf8, false),
40        Field::new("line_start", DataType::UInt32, false),
41        Field::new("col_start", DataType::UInt32, false),
42        Field::new("line_end", DataType::UInt32, false),
43        Field::new("col_end", DataType::UInt32, false),
44        Field::new("signature", DataType::Utf8, true),
45        Field::new("docstring", DataType::Utf8, true),
46        Field::new("fp_sig", DataType::UInt64, true),
47        Field::new("fp_body", DataType::UInt64, true),
48        Field::new("fp_shape", DataType::UInt64, true),
49    ]))
50}
51
52fn edges_schema() -> Arc<Schema> {
53    Arc::new(Schema::new(vec![
54        Field::new("src_id", DataType::Utf8, false),
55        Field::new("dst_id", DataType::Utf8, false),
56        Field::new("kind", DataType::Utf8, false),
57        Field::new("confidence", DataType::Float32, false),
58        Field::new("cross_repo", DataType::Boolean, false),
59    ]))
60}
61
62fn symbols_batch(symbols: &[Symbol]) -> Result<RecordBatch> {
63    let arrays: Vec<ArrayRef> = vec![
64        Arc::new(StringArray::from_iter_values(
65            symbols.iter().map(|s| s.id.render()),
66        )),
67        Arc::new(StringArray::from_iter_values(
68            symbols.iter().map(|s| s.id.scheme.clone()),
69        )),
70        Arc::new(StringArray::from_iter_values(
71            symbols.iter().map(|s| s.id.package_manager.clone()),
72        )),
73        Arc::new(StringArray::from_iter_values(
74            symbols.iter().map(|s| s.id.package.clone()),
75        )),
76        Arc::new(StringArray::from_iter_values(
77            symbols.iter().map(|s| s.id.version.clone()),
78        )),
79        Arc::new(StringArray::from_iter_values(
80            symbols.iter().map(|s| s.id.descriptor.clone()),
81        )),
82        Arc::new(StringArray::from_iter_values(
83            symbols.iter().map(|s| s.name.clone()),
84        )),
85        Arc::new(StringArray::from_iter_values(
86            symbols.iter().map(|s| format!("{:?}", s.kind)),
87        )),
88        Arc::new(StringArray::from_iter_values(
89            symbols.iter().map(|s| format!("{:?}", s.language)),
90        )),
91        Arc::new(StringArray::from_iter_values(
92            symbols.iter().map(|s| s.repo.0.clone()),
93        )),
94        Arc::new(StringArray::from_iter_values(
95            symbols.iter().map(|s| s.file_path.clone()),
96        )),
97        Arc::new(UInt32Array::from_iter_values(
98            symbols.iter().map(|s| s.range.start.line),
99        )),
100        Arc::new(UInt32Array::from_iter_values(
101            symbols.iter().map(|s| s.range.start.column),
102        )),
103        Arc::new(UInt32Array::from_iter_values(
104            symbols.iter().map(|s| s.range.end.line),
105        )),
106        Arc::new(UInt32Array::from_iter_values(
107            symbols.iter().map(|s| s.range.end.column),
108        )),
109        Arc::new(StringArray::from_iter(
110            symbols.iter().map(|s| s.signature.clone()),
111        )),
112        Arc::new(StringArray::from_iter(
113            symbols.iter().map(|s| s.docstring.clone()),
114        )),
115        Arc::new(UInt64Array::from_iter(
116            symbols.iter().map(|s| s.fingerprint.map(|f| f.sig)),
117        )),
118        Arc::new(UInt64Array::from_iter(
119            symbols.iter().map(|s| s.fingerprint.map(|f| f.body)),
120        )),
121        Arc::new(UInt64Array::from_iter(
122            symbols.iter().map(|s| s.fingerprint.map(|f| f.shape)),
123        )),
124    ];
125    RecordBatch::try_new(symbols_schema(), arrays).context("build symbols batch")
126}
127
128fn edges_batch(edges: &[Edge]) -> Result<RecordBatch> {
129    let arrays: Vec<ArrayRef> = vec![
130        Arc::new(StringArray::from_iter_values(
131            edges.iter().map(|e| e.src.render()),
132        )),
133        Arc::new(StringArray::from_iter_values(
134            edges.iter().map(|e| e.dst.render()),
135        )),
136        Arc::new(StringArray::from_iter_values(
137            edges.iter().map(|e| format!("{:?}", e.kind)),
138        )),
139        Arc::new(Float32Array::from_iter_values(
140            edges.iter().map(|e| e.confidence),
141        )),
142        Arc::new(BooleanArray::from(
143            edges.iter().map(|e| e.cross_repo).collect::<Vec<_>>(),
144        )),
145    ];
146    RecordBatch::try_new(edges_schema(), arrays).context("build edges batch")
147}
148
149/// Write `batch` as the full contents of table `name`, creating it if absent and otherwise
150/// overwriting in place. Overwrite creates a **new Lance version** while retaining prior
151/// versions — so the newest version manifest is the atomic "latest" pointer, and older
152/// versions remain available for pinning/rollback (`checkout`). Returns the new version.
153pub(crate) async fn write_versioned(
154    db: &lancedb::Connection,
155    name: &str,
156    batch: RecordBatch,
157) -> Result<u64> {
158    // lancedb 0.31 implements `Scannable` for `RecordBatch`, so pass it directly.
159    let tbl = match db.open_table(name).execute().await {
160        Ok(t) => {
161            t.add(batch)
162                .mode(AddDataMode::Overwrite)
163                .execute()
164                .await
165                .with_context(|| format!("overwrite table `{name}`"))?;
166            t
167        }
168        Err(_) => db
169            .create_table(name, batch)
170            .execute()
171            .await
172            .with_context(|| format!("create table `{name}`"))?,
173    };
174    tbl.version()
175        .await
176        .with_context(|| format!("read version of `{name}`"))
177}
178
179/// Write the symbol/edge graph to a LanceDB dataset at `uri`
180/// (e.g. `s3://bucket/prefix` or a local path). Returns the new `(symbols, edges)` versions.
181pub async fn write_graph(uri: &str, symbols: &[Symbol], edges: &[Edge]) -> Result<(u64, u64)> {
182    let db = crate::storage::connect(uri)
183        .execute()
184        .await
185        .with_context(|| format!("connect lancedb at {uri}"))?;
186    let sv = write_versioned(&db, "symbols", symbols_batch(symbols)?).await?;
187    let ev = write_versioned(&db, "edges", edges_batch(edges)?).await?;
188    Ok((sv, ev))
189}
190
191/// Read the current `(symbols, edges)` versions — the atomic latest pointer.
192pub async fn latest_versions(uri: &str) -> Result<(u64, u64)> {
193    let db = crate::storage::connect(uri).execute().await?;
194    let sv = db.open_table("symbols").execute().await?.version().await?;
195    let ev = db.open_table("edges").execute().await?.version().await?;
196    Ok((sv, ev))
197}
198
199// ---- reading the persisted graph back into core types ----------------------------------
200
201use crate::model::{
202    EdgeKind, Fingerprint, GlobalSymbolId, Language, Position, Range, RepoId, SymbolKind,
203};
204use futures::TryStreamExt;
205use lancedb::arrow::arrow_array::Array;
206use lancedb::query::ExecutableQuery;
207
208fn kind_from_str(s: &str) -> SymbolKind {
209    match s {
210        "File" => SymbolKind::File,
211        "Module" => SymbolKind::Module,
212        "Namespace" => SymbolKind::Namespace,
213        "Class" => SymbolKind::Class,
214        "Interface" => SymbolKind::Interface,
215        "Trait" => SymbolKind::Trait,
216        "Enum" => SymbolKind::Enum,
217        "Struct" => SymbolKind::Struct,
218        "Function" => SymbolKind::Function,
219        "Method" => SymbolKind::Method,
220        "Field" => SymbolKind::Field,
221        "Variable" => SymbolKind::Variable,
222        "Constant" => SymbolKind::Constant,
223        "Import" => SymbolKind::Import,
224        "TypeAlias" => SymbolKind::TypeAlias,
225        "Process" => SymbolKind::Process,
226        "Contract" => SymbolKind::Contract,
227        _ => SymbolKind::Variable,
228    }
229}
230
231fn lang_from_str(s: &str) -> Language {
232    match s {
233        "Python" => Language::Python,
234        "TypeScript" => Language::TypeScript,
235        "JavaScript" => Language::JavaScript,
236        "Go" => Language::Go,
237        "Rust" => Language::Rust,
238        "Java" => Language::Java,
239        "Kotlin" => Language::Kotlin,
240        "Ruby" => Language::Ruby,
241        "CSharp" => Language::CSharp,
242        "Cpp" => Language::Cpp,
243        "C" => Language::C,
244        "Php" => Language::Php,
245        "Scala" => Language::Scala,
246        "Swift" => Language::Swift,
247        // `Other("x")` Debug form, or anything unknown
248        other => Language::Other(
249            other
250                .strip_prefix("Other(\"")
251                .and_then(|s| s.strip_suffix("\")"))
252                .unwrap_or(other)
253                .to_string(),
254        ),
255    }
256}
257
258/// Parse a rendered SCIP id (`scheme pm package version descriptor`) back into components.
259fn id_from_rendered(s: &str) -> GlobalSymbolId {
260    let mut it = s.splitn(5, ' ');
261    let mut next = || it.next().unwrap_or(".").to_string();
262    GlobalSymbolId {
263        scheme: next(),
264        package_manager: next(),
265        package: next(),
266        version: next(),
267        descriptor: next(),
268    }
269}
270
271pub(crate) fn str_col<'a>(
272    batch: &'a lancedb::arrow::arrow_array::RecordBatch,
273    name: &str,
274) -> Result<&'a StringArray> {
275    batch
276        .column_by_name(name)
277        .and_then(|c| c.as_any().downcast_ref::<StringArray>())
278        .with_context(|| format!("column `{name}` missing or not Utf8"))
279}
280
281fn u32_col<'a>(
282    batch: &'a lancedb::arrow::arrow_array::RecordBatch,
283    name: &str,
284) -> Result<&'a UInt32Array> {
285    batch
286        .column_by_name(name)
287        .and_then(|c| c.as_any().downcast_ref::<UInt32Array>())
288        .with_context(|| format!("column `{name}` missing or not UInt32"))
289}
290
291/// Load the persisted org graph back into core types. This is the fast consumer path:
292/// `serve`/`explore` load once from Lance (local mmap or S3) instead of re-parsing.
293pub async fn read_graph(uri: &str) -> Result<(Vec<Symbol>, Vec<Edge>)> {
294    read_graph_at(uri, None).await
295}
296
297/// The graph at given `(symbols, edges)` table versions, or the latest.
298pub async fn read_graph_at(uri: &str, at: Option<(u64, u64)>) -> Result<(Vec<Symbol>, Vec<Edge>)> {
299    let db = crate::storage::connect(uri).execute().await?;
300    read_graph_in(&db, at).await
301}
302
303async fn read_graph_in(
304    db: &lancedb::Connection,
305    at: Option<(u64, u64)>,
306) -> Result<(Vec<Symbol>, Vec<Edge>)> {
307    let open = |name: &'static str, v: Option<u64>| async move {
308        let t = db.open_table(name).execute().await?;
309        if let Some(v) = v {
310            t.checkout(v).await?;
311        }
312        anyhow::Ok(t)
313    };
314
315    // symbols
316    let mut symbols = Vec::new();
317    let mut st = open("symbols", at.map(|a| a.0))
318        .await?
319        .query()
320        .execute()
321        .await?;
322    while let Some(b) = st.try_next().await? {
323        let (scheme, pm, pkg, ver, desc) = (
324            str_col(&b, "scheme")?,
325            str_col(&b, "package_manager")?,
326            str_col(&b, "package")?,
327            str_col(&b, "version")?,
328            str_col(&b, "descriptor")?,
329        );
330        let (name, kind, lang, repo, file) = (
331            str_col(&b, "name")?,
332            str_col(&b, "kind")?,
333            str_col(&b, "language")?,
334            str_col(&b, "repo")?,
335            str_col(&b, "file_path")?,
336        );
337        let (ls, cs, le, ce) = (
338            u32_col(&b, "line_start")?,
339            u32_col(&b, "col_start")?,
340            u32_col(&b, "line_end")?,
341            u32_col(&b, "col_end")?,
342        );
343        let sig = str_col(&b, "signature")?;
344        let doc = str_col(&b, "docstring")?;
345        // Absent in indexes written before fingerprints existed.
346        let fp = |name: &str| {
347            b.column_by_name(name)
348                .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
349        };
350        let (fs, fb, fsh) = (fp("fp_sig"), fp("fp_body"), fp("fp_shape"));
351        for i in 0..b.num_rows() {
352            symbols.push(Symbol {
353                id: GlobalSymbolId {
354                    scheme: scheme.value(i).to_string(),
355                    package_manager: pm.value(i).to_string(),
356                    package: pkg.value(i).to_string(),
357                    version: ver.value(i).to_string(),
358                    descriptor: desc.value(i).to_string(),
359                },
360                name: name.value(i).to_string(),
361                kind: kind_from_str(kind.value(i)),
362                language: lang_from_str(lang.value(i)),
363                repo: RepoId(repo.value(i).to_string()),
364                file_path: file.value(i).to_string(),
365                range: Range {
366                    start: Position {
367                        line: ls.value(i),
368                        column: cs.value(i),
369                    },
370                    end: Position {
371                        line: le.value(i),
372                        column: ce.value(i),
373                    },
374                },
375                signature: (!sig.is_null(i)).then(|| sig.value(i).to_string()),
376                docstring: (!doc.is_null(i)).then(|| doc.value(i).to_string()),
377                fingerprint: match (fs, fb, fsh) {
378                    (Some(a), Some(c), Some(d)) if !a.is_null(i) => Some(Fingerprint {
379                        sig: a.value(i),
380                        body: c.value(i),
381                        shape: d.value(i),
382                    }),
383                    _ => None,
384                },
385            });
386        }
387    }
388
389    // edges
390    let mut edges = Vec::new();
391    let mut st = open("edges", at.map(|a| a.1))
392        .await?
393        .query()
394        .execute()
395        .await?;
396    while let Some(b) = st.try_next().await? {
397        let src = str_col(&b, "src_id")?;
398        let dst = str_col(&b, "dst_id")?;
399        let kind = str_col(&b, "kind")?;
400        let conf = b
401            .column_by_name("confidence")
402            .and_then(|c| c.as_any().downcast_ref::<Float32Array>())
403            .context("confidence column")?;
404        let cross = b
405            .column_by_name("cross_repo")
406            .and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
407            .context("cross_repo column")?;
408        for i in 0..b.num_rows() {
409            edges.push(Edge {
410                src: id_from_rendered(src.value(i)),
411                dst: id_from_rendered(dst.value(i)),
412                kind: edge_kind_from_str(kind.value(i)),
413                confidence: conf.value(i),
414                cross_repo: cross.value(i),
415            });
416        }
417    }
418
419    Ok((symbols, edges))
420}
421
422fn edge_kind_from_str(s: &str) -> EdgeKind {
423    match s {
424        "Contains" => EdgeKind::Contains,
425        "Imports" => EdgeKind::Imports,
426        "Calls" => EdgeKind::Calls,
427        "Instantiates" => EdgeKind::Instantiates,
428        "Inherits" => EdgeKind::Inherits,
429        "Implements" => EdgeKind::Implements,
430        "References" => EdgeKind::References,
431        "Defines" => EdgeKind::Defines,
432        "Uses" => EdgeKind::Uses,
433        "ParticipatesIn" => EdgeKind::ParticipatesIn,
434        "Serves" => EdgeKind::Serves,
435        "Consumes" => EdgeKind::Consumes,
436        "Publishes" => EdgeKind::Publishes,
437        "Subscribes" => EdgeKind::Subscribes,
438        "Reads" => EdgeKind::Reads,
439        "Writes" => EdgeKind::Writes,
440        _ => EdgeKind::References,
441    }
442}
443
444/// Blocking wrapper for [`read_graph`].
445/// Graph versions as `(symbols version, edges version, UTC time)`, oldest first. Each graph
446/// write commits both tables, so the n-th versions of each belong together.
447pub async fn graph_versions(uri: &str) -> Result<Vec<(u64, u64, String)>> {
448    let db = crate::storage::connect(uri).execute().await?;
449    let s = db.open_table("symbols").execute().await?;
450    let e = db.open_table("edges").execute().await?;
451    versions_of(&s, &e).await
452}
453
454async fn versions_of(s: &lancedb::Table, e: &lancedb::Table) -> Result<Vec<(u64, u64, String)>> {
455    let (s, e) = (s.list_versions().await?, e.list_versions().await?);
456    Ok(s.iter()
457        .zip(e.iter())
458        .map(|(a, b)| (a.version, b.version, a.timestamp.to_rfc3339()))
459        .collect())
460}
461
462pub fn graph_versions_blocking(uri: &str) -> Result<Vec<(u64, u64, String)>> {
463    crate::rt::block_on(graph_versions(uri))
464}
465
466pub fn read_graph_blocking(uri: &str) -> Result<(Vec<Symbol>, Vec<Edge>)> {
467    crate::rt::block_on(read_graph(uri))
468}
469
470/// Read back `(symbol_count, edge_count)` — proves the persisted tables are queryable.
471pub async fn count_rows(uri: &str) -> Result<(usize, usize)> {
472    let db = crate::storage::connect(uri).execute().await?;
473    let symbols = db
474        .open_table("symbols")
475        .execute()
476        .await?
477        .count_rows(None)
478        .await?;
479    let edges = db
480        .open_table("edges")
481        .execute()
482        .await?
483        .count_rows(None)
484        .await?;
485    Ok((symbols, edges))
486}
487
488/// Blocking wrapper so the (sync) CLI can persist without an async main.
489/// Returns the new `(symbols, edges)` Lance versions.
490pub fn write_graph_blocking(uri: &str, symbols: &[Symbol], edges: &[Edge]) -> Result<(u64, u64)> {
491    crate::rt::block_on(write_graph(uri, symbols, edges))
492}
493
494/// Blocking wrapper for the read-back count.
495pub fn count_rows_blocking(uri: &str) -> Result<(usize, usize)> {
496    crate::rt::block_on(count_rows(uri))
497}
498
499/// Blocking wrapper for the current `(symbols, edges)` versions.
500pub fn latest_versions_blocking(uri: &str) -> Result<(u64, u64)> {
501    crate::rt::block_on(latest_versions(uri))
502}
503
504// ---- embeddings (semantic search vectors) -----------------------------------------------
505
506// ---- repo metadata (last-indexed commit for incremental) --------------------------------
507
508/// Record the commit a repo dataset was indexed at (single-row `repo_meta` table).
509pub async fn write_repo_meta(uri: &str, repo: &str, commit: &str) -> Result<()> {
510    let schema = Arc::new(Schema::new(vec![
511        Field::new("repo", DataType::Utf8, false),
512        Field::new("commit", DataType::Utf8, false),
513    ]));
514    let batch = RecordBatch::try_new(
515        schema,
516        vec![
517            Arc::new(StringArray::from(vec![repo])) as ArrayRef,
518            Arc::new(StringArray::from(vec![commit])) as ArrayRef,
519        ],
520    )
521    .context("build repo_meta batch")?;
522    let db = crate::storage::connect(uri).execute().await?;
523    write_versioned(&db, "repo_meta", batch).await?;
524    Ok(())
525}
526
527/// The commit a repo dataset was last indexed at, if recorded.
528pub async fn read_repo_meta(uri: &str) -> Result<Option<String>> {
529    let db = crate::storage::connect(uri).execute().await?;
530    let Ok(tbl) = db.open_table("repo_meta").execute().await else {
531        return Ok(None);
532    };
533    let mut st = tbl.query().execute().await?;
534    while let Some(b) = st.try_next().await? {
535        if b.num_rows() > 0 {
536            return Ok(Some(str_col(&b, "commit")?.value(0).to_string()));
537        }
538    }
539    Ok(None)
540}
541
542/// Blocking wrapper for [`write_repo_meta`].
543pub fn write_repo_meta_blocking(uri: &str, repo: &str, commit: &str) -> Result<()> {
544    crate::rt::block_on(write_repo_meta(uri, repo, commit))
545}
546
547/// Record the indexed commit for *several* repos (multi-row `repo_meta`) — used by the org
548/// `link` so an incremental rebuild knows which repos changed.
549pub async fn write_repo_commits(uri: &str, repos: &[IndexedRepo]) -> Result<()> {
550    if repos.is_empty() {
551        return Ok(());
552    }
553    let schema = Arc::new(Schema::new(vec![
554        Field::new("repo", DataType::Utf8, false),
555        Field::new("commit", DataType::Utf8, false),
556        Field::new("remote", DataType::Utf8, true),
557        Field::new("branch", DataType::Utf8, true),
558    ]));
559    let batch = RecordBatch::try_new(
560        schema,
561        vec![
562            Arc::new(StringArray::from_iter_values(
563                repos.iter().map(|r| r.name.clone()),
564            )) as ArrayRef,
565            Arc::new(StringArray::from_iter_values(
566                repos.iter().map(|r| r.commit.clone()),
567            )) as ArrayRef,
568            Arc::new(StringArray::from_iter(
569                repos.iter().map(|r| r.remote.clone()),
570            )) as ArrayRef,
571            Arc::new(StringArray::from_iter(
572                repos.iter().map(|r| r.branch.clone()),
573            )) as ArrayRef,
574        ],
575    )
576    .context("build repo_commits batch")?;
577    let db = crate::storage::connect(uri).execute().await?;
578    write_versioned(&db, "repo_meta", batch).await?;
579    Ok(())
580}
581
582/// A repository in the index: its name, the commit and branch indexed and where it is cloned from.
583#[derive(Debug, Clone, PartialEq, serde::Serialize)]
584pub struct IndexedRepo {
585    pub name: String,
586    pub commit: String,
587    pub remote: Option<String>,
588    pub branch: Option<String>,
589}
590
591/// The repositories recorded in the index (indexes made before remotes were stored have none).
592pub async fn read_indexed_repos(uri: &str) -> Result<Vec<IndexedRepo>> {
593    let db = crate::storage::connect(uri).execute().await?;
594    let Ok(tbl) = db.open_table("repo_meta").execute().await else {
595        return Ok(Vec::new());
596    };
597    let mut out = Vec::new();
598    let mut st = tbl.query().execute().await?;
599    while let Some(b) = st.try_next().await? {
600        let repo = str_col(&b, "repo")?;
601        let commit = str_col(&b, "commit")?;
602        let remote = str_col(&b, "remote").ok();
603        let branch = str_col(&b, "branch").ok();
604        for i in 0..b.num_rows() {
605            out.push(IndexedRepo {
606                name: repo.value(i).to_string(),
607                commit: commit.value(i).to_string(),
608                remote: remote
609                    .filter(|r| r.is_valid(i))
610                    .map(|r| r.value(i).to_string()),
611                branch: branch
612                    .filter(|r| r.is_valid(i))
613                    .map(|r| r.value(i).to_string()),
614            });
615        }
616    }
617    out.sort_by(|a, b| a.name.cmp(&b.name));
618    Ok(out)
619}
620
621/// The indexed commit of each repository, by name.
622pub async fn read_repo_commits(uri: &str) -> Result<std::collections::HashMap<String, String>> {
623    Ok(read_indexed_repos(uri)
624        .await?
625        .into_iter()
626        .map(|r| (r.name, r.commit))
627        .collect())
628}
629
630/// Blocking wrapper for [`write_repo_commits`].
631pub fn write_repo_commits_blocking(uri: &str, repos: &[IndexedRepo]) -> Result<()> {
632    crate::rt::block_on(write_repo_commits(uri, repos))
633}
634
635/// Blocking wrapper for [`read_repo_commits`].
636pub fn read_repo_commits_blocking(uri: &str) -> Result<std::collections::HashMap<String, String>> {
637    crate::rt::block_on(read_repo_commits(uri))
638}
639
640/// Blocking wrapper for [`read_repo_meta`].
641pub fn read_repo_meta_blocking(uri: &str) -> Result<Option<String>> {
642    crate::rt::block_on(read_repo_meta(uri))
643}
644
645// ---- hybrid search table (BM25 FTS + vector, native LanceDB fusion) ---------------------
646
647use lance_index::scalar::FullTextSearchQuery;
648use lancedb::index::scalar::FtsIndexBuilder;
649use lancedb::index::vector::IvfHnswFlatIndexBuilder;
650use lancedb::index::Index;
651use lancedb::query::{QueryBase, QueryExecutionOptions, Select};
652
653fn search_schema(dim: usize) -> Arc<Schema> {
654    let item = Arc::new(Field::new("item", DataType::Float32, true));
655    Arc::new(Schema::new(vec![
656        Field::new("symbol_id", DataType::Utf8, false),
657        Field::new("text", DataType::Utf8, false),
658        Field::new("vector", DataType::FixedSizeList(item, dim as i32), false),
659    ]))
660}
661
662/// Write the denormalized retrieval table (`symbol_id`, `text`, `vector`) and build a native
663/// BM25 full-text index on `text`. Hybrid search then fuses this FTS index with vector search
664/// using LanceDB's own RRF reranker — no hand-rolled BM25.
665pub async fn write_search_table(uri: &str, rows: &[SearchRow]) -> Result<u64> {
666    let Some((_, _, first)) = rows.first() else {
667        return Ok(0);
668    };
669    let dim = first.len();
670    let ids = StringArray::from_iter_values(rows.iter().map(|(id, _, _)| id.render()));
671    let texts = StringArray::from_iter_values(rows.iter().map(|(_, t, _)| t.clone()));
672    let flat: Vec<f32> = rows
673        .iter()
674        .flat_map(|(_, _, v)| v.iter().copied())
675        .collect();
676    let item = Arc::new(Field::new("item", DataType::Float32, true));
677    let vectors =
678        FixedSizeListArray::try_new(item, dim as i32, Arc::new(Float32Array::from(flat)), None)
679            .context("build vector column")?;
680    let batch = RecordBatch::try_new(
681        search_schema(dim),
682        vec![
683            Arc::new(ids) as ArrayRef,
684            Arc::new(texts) as ArrayRef,
685            Arc::new(vectors) as ArrayRef,
686        ],
687    )
688    .context("build search batch")?;
689
690    let db = crate::storage::connect(uri).execute().await?;
691    let version = write_versioned(&db, "search", batch).await?;
692
693    // (Re)build the BM25 full-text index on `text`.
694    let tbl = db.open_table("search").execute().await?;
695    tbl.create_index(&["text"], Index::FTS(FtsIndexBuilder::default()))
696        .execute()
697        .await
698        .context("create FTS index")?;
699
700    // An ANN index LanceDB keeps in its cache, so a query on object storage reads only the rows
701    // it returns instead of every vector.
702    let parts = (rows.len() / 20_000).max(1) as u32;
703    let ann = IvfHnswFlatIndexBuilder::default().num_partitions(parts);
704    if let Err(e) = tbl
705        .create_index(&["vector"], Index::IvfHnswFlat(ann))
706        .execute()
707        .await
708    {
709        eprintln!("comindly: vector ANN index skipped (non-fatal): {e}");
710    }
711    Ok(version)
712}
713
714/// Read `(symbol_id, vector)` from the search table for incremental reuse. `Ok(None)` if absent.
715pub async fn read_search_vectors(uri: &str) -> Result<Option<Vec<VectorRow>>> {
716    let db = crate::storage::connect(uri).execute().await?;
717    let Ok(tbl) = db.open_table("search").execute().await else {
718        return Ok(None);
719    };
720    let mut out = Vec::new();
721    let mut st = tbl
722        .query()
723        .select(Select::columns(&["symbol_id", "vector"]))
724        .execute()
725        .await?;
726    while let Some(b) = st.try_next().await? {
727        let ids = str_col(&b, "symbol_id")?;
728        let vecs = b
729            .column_by_name("vector")
730            .and_then(|c| c.as_any().downcast_ref::<FixedSizeListArray>())
731            .context("vector column")?;
732        for i in 0..b.num_rows() {
733            let sub = vecs.value(i);
734            let f = sub.as_any().downcast_ref::<Float32Array>().context("f32")?;
735            out.push((id_from_rendered(ids.value(i)), f.values().to_vec()));
736        }
737    }
738    Ok(Some(out))
739}
740
741/// Native hybrid search: BM25 full-text + vector, fused by LanceDB's RRF reranker. Returns
742/// `(rendered_symbol_id, relevance_score)` best-first. `query_vector` must match the index dim.
743pub async fn hybrid_search(
744    uri: &str,
745    query_text: &str,
746    query_vector: Vec<f32>,
747    k: usize,
748) -> Result<Vec<(String, f32)>> {
749    let db = crate::storage::connect(uri).execute().await?;
750    let tbl = db.open_table("search").execute().await?;
751    hybrid_in(&tbl, query_text, query_vector, k).await
752}
753
754async fn hybrid_in(
755    tbl: &lancedb::Table,
756    query_text: &str,
757    query_vector: Vec<f32>,
758    k: usize,
759) -> Result<Vec<(String, f32)>> {
760    let mut st = tbl
761        .query()
762        .select(lancedb::query::Select::columns(&["symbol_id"]))
763        .full_text_search(FullTextSearchQuery::new(query_text.to_owned()))
764        .nearest_to(query_vector)?
765        .limit(k)
766        // execute_hybrid fuses FTS + vector with an RRF reranker (k=60) by default.
767        .execute_hybrid(QueryExecutionOptions::default())
768        .await?;
769
770    let mut out = Vec::new();
771    let mut rank = 0usize;
772    while let Some(b) = st.try_next().await? {
773        let ids = str_col(&b, "symbol_id")?;
774        // LanceDB attaches a `_relevance_score` column after reranking; fall back to rank order.
775        let scores = b
776            .column_by_name("_relevance_score")
777            .and_then(|c| c.as_any().downcast_ref::<Float32Array>());
778        for i in 0..b.num_rows() {
779            let score = scores
780                .map(|s| s.value(i))
781                .unwrap_or(1.0 / (rank as f32 + 1.0));
782            out.push((ids.value(i).to_string(), score));
783            rank += 1;
784        }
785    }
786    Ok(out)
787}
788
789/// Open tables of one index for a long-running reader: LanceDB keeps their metadata and indexes in
790/// its session cache and re-checks for a newer version at most every `fresh`.
791pub struct Tables {
792    db: lancedb::Connection,
793    open: tokio::sync::Mutex<std::collections::HashMap<&'static str, lancedb::Table>>,
794}
795
796impl Tables {
797    pub async fn connect(uri: &str, fresh: std::time::Duration) -> Result<Tables> {
798        let db = crate::storage::connect(uri)
799            .read_consistency_interval(fresh)
800            .execute()
801            .await
802            .with_context(|| format!("connect lancedb at {uri}"))?;
803        Ok(Tables {
804            db,
805            open: Default::default(),
806        })
807    }
808
809    async fn table(&self, name: &'static str) -> Result<lancedb::Table> {
810        let mut open = self.open.lock().await;
811        if let Some(t) = open.get(name) {
812            return Ok(t.clone());
813        }
814        let t = self.db.open_table(name).execute().await?;
815        open.insert(name, t.clone());
816        Ok(t)
817    }
818
819    pub async fn hybrid_search(
820        &self,
821        query_text: &str,
822        query_vector: Vec<f32>,
823        k: usize,
824    ) -> Result<Vec<(String, f32)>> {
825        hybrid_in(&self.table("search").await?, query_text, query_vector, k).await
826    }
827
828    pub async fn versions(&self) -> Result<Vec<(u64, u64, String)>> {
829        versions_of(&self.table("symbols").await?, &self.table("edges").await?).await
830    }
831
832    /// The graph at `(symbols, edges)` versions; fresh handles, so the shared ones keep tracking
833    /// the latest version.
834    pub async fn graph_at(&self, at: (u64, u64)) -> Result<(Vec<Symbol>, Vec<Edge>)> {
835        read_graph_in(&self.db, Some(at)).await
836    }
837}
838
839/// Blocking wrappers.
840pub fn write_search_table_blocking(uri: &str, rows: &[SearchRow]) -> Result<u64> {
841    crate::rt::block_on(write_search_table(uri, rows))
842}
843pub fn read_search_vectors_blocking(uri: &str) -> Result<Option<Vec<VectorRow>>> {
844    crate::rt::block_on(read_search_vectors(uri))
845}
846pub fn hybrid_search_blocking(
847    uri: &str,
848    text: &str,
849    vector: Vec<f32>,
850    k: usize,
851) -> Result<Vec<(String, f32)>> {
852    crate::rt::block_on(hybrid_search(uri, text, vector, k))
853}
854
855// ---- LLM enrichment (summaries + generated queries) -------------------------------------
856
857fn enrichment_schema() -> Arc<Schema> {
858    Arc::new(Schema::new(vec![
859        Field::new("symbol_id", DataType::Utf8, false),
860        Field::new("summary", DataType::Utf8, false),
861        Field::new("queries", DataType::Utf8, false), // JSON array of strings
862    ]))
863}
864
865/// Persist LLM enrichment (one-line summary + generated NL queries) as a Lance `enrichment`
866/// table. `queries` is a JSON array string. Returns the new version. No-op if empty.
867pub async fn write_enrichment(uri: &str, rows: &[TextRow]) -> Result<u64> {
868    if rows.is_empty() {
869        return Ok(0);
870    }
871    let ids = StringArray::from_iter_values(rows.iter().map(|(id, _, _)| id.render()));
872    let summaries = StringArray::from_iter_values(rows.iter().map(|(_, s, _)| s.clone()));
873    let queries = StringArray::from_iter_values(
874        rows.iter()
875            .map(|(_, _, q)| serde_json::to_string(q).unwrap_or_else(|_| "[]".into())),
876    );
877    let batch = RecordBatch::try_new(
878        enrichment_schema(),
879        vec![
880            Arc::new(ids) as ArrayRef,
881            Arc::new(summaries) as ArrayRef,
882            Arc::new(queries) as ArrayRef,
883        ],
884    )
885    .context("build enrichment batch")?;
886    let db = crate::storage::connect(uri).execute().await?;
887    write_versioned(&db, "enrichment", batch).await
888}
889
890/// Load persisted enrichment as `(symbol_id, summary, queries)`. `Ok(None)` if absent.
891pub async fn read_enrichment(uri: &str) -> Result<Option<Vec<TextRow>>> {
892    let db = crate::storage::connect(uri).execute().await?;
893    let Ok(tbl) = db.open_table("enrichment").execute().await else {
894        return Ok(None);
895    };
896    let mut out = Vec::new();
897    let mut st = tbl.query().execute().await?;
898    while let Some(b) = st.try_next().await? {
899        let ids = str_col(&b, "symbol_id")?;
900        let summaries = str_col(&b, "summary")?;
901        let queries = str_col(&b, "queries")?;
902        for i in 0..b.num_rows() {
903            let q: Vec<String> = serde_json::from_str(queries.value(i)).unwrap_or_default();
904            out.push((
905                id_from_rendered(ids.value(i)),
906                summaries.value(i).to_string(),
907                q,
908            ));
909        }
910    }
911    Ok(Some(out))
912}
913
914/// Blocking wrapper for [`write_enrichment`].
915pub fn write_enrichment_blocking(uri: &str, rows: &[TextRow]) -> Result<u64> {
916    crate::rt::block_on(write_enrichment(uri, rows))
917}
918
919/// Blocking wrapper for [`read_enrichment`].
920pub fn read_enrichment_blocking(uri: &str) -> Result<Option<Vec<TextRow>>> {
921    crate::rt::block_on(read_enrichment(uri))
922}
923
924/// What each generated text was written from, as `(id, fingerprint)` in `table`; same fingerprint,
925/// same input, so the text is reused instead of asking the LLM again.
926pub async fn write_keys(uri: &str, table: &str, keys: &[(String, String)]) -> Result<u64> {
927    let schema = Arc::new(Schema::new(vec![
928        Field::new("symbol_id", DataType::Utf8, false),
929        Field::new("key", DataType::Utf8, false),
930    ]));
931    let batch = RecordBatch::try_new(
932        schema,
933        vec![
934            Arc::new(StringArray::from_iter_values(
935                keys.iter().map(|(i, _)| i.as_str()),
936            )) as ArrayRef,
937            Arc::new(StringArray::from_iter_values(
938                keys.iter().map(|(_, k)| k.as_str()),
939            )) as ArrayRef,
940        ],
941    )?;
942    let db = crate::storage::connect(uri).execute().await?;
943    write_versioned(&db, table, batch).await
944}
945
946/// The fingerprints [`write_keys`] stored in `table`, by id.
947pub async fn read_keys(uri: &str, table: &str) -> Result<HashMap<String, String>> {
948    let db = crate::storage::connect(uri).execute().await?;
949    let tbl = db.open_table(table).execute().await?;
950    let mut out = HashMap::new();
951    let mut st = tbl.query().execute().await?;
952    while let Some(b) = st.try_next().await? {
953        let (ids, keys) = (str_col(&b, "symbol_id")?, str_col(&b, "key")?);
954        for i in 0..b.num_rows() {
955            out.insert(ids.value(i).to_string(), keys.value(i).to_string());
956        }
957    }
958    Ok(out)
959}
960
961pub fn write_keys_blocking(uri: &str, table: &str, keys: &[(String, String)]) -> Result<u64> {
962    crate::rt::block_on(write_keys(uri, table, keys))
963}
964
965pub fn read_keys_blocking(uri: &str, table: &str) -> HashMap<String, String> {
966    crate::rt::block_on(read_keys(uri, table)).unwrap_or_default()
967}
968
969fn flows_schema() -> Arc<Schema> {
970    Arc::new(Schema::new(vec![
971        Field::new("symbol_id", DataType::Utf8, false),
972        Field::new("narration", DataType::Utf8, false),
973        Field::new("queries", DataType::Utf8, false), // JSON array of strings
974    ]))
975}
976
977/// Persist pre-generated flow walkthroughs: `(entry_id, narration_markdown, flow_questions)`.
978pub async fn write_flows(uri: &str, rows: &[TextRow]) -> Result<u64> {
979    if rows.is_empty() {
980        return Ok(0);
981    }
982    let ids = StringArray::from_iter_values(rows.iter().map(|(id, _, _)| id.render()));
983    let narr = StringArray::from_iter_values(rows.iter().map(|(_, n, _)| n.clone()));
984    let queries = StringArray::from_iter_values(
985        rows.iter()
986            .map(|(_, _, q)| serde_json::to_string(q).unwrap_or_else(|_| "[]".into())),
987    );
988    let batch = RecordBatch::try_new(
989        flows_schema(),
990        vec![
991            Arc::new(ids) as ArrayRef,
992            Arc::new(narr) as ArrayRef,
993            Arc::new(queries) as ArrayRef,
994        ],
995    )
996    .context("build flows batch")?;
997    let db = crate::storage::connect(uri).execute().await?;
998    write_versioned(&db, "flows", batch).await
999}
1000
1001/// Load persisted flows as `(entry_id, narration, queries)`. `Ok(None)` if absent.
1002pub async fn read_flows(uri: &str) -> Result<Option<Vec<TextRow>>> {
1003    let db = crate::storage::connect(uri).execute().await?;
1004    let Ok(tbl) = db.open_table("flows").execute().await else {
1005        return Ok(None);
1006    };
1007    let mut out = Vec::new();
1008    let mut st = tbl.query().execute().await?;
1009    while let Some(b) = st.try_next().await? {
1010        let ids = str_col(&b, "symbol_id")?;
1011        let narr = str_col(&b, "narration")?;
1012        let queries = str_col(&b, "queries")?;
1013        for i in 0..b.num_rows() {
1014            let q: Vec<String> = serde_json::from_str(queries.value(i)).unwrap_or_default();
1015            out.push((id_from_rendered(ids.value(i)), narr.value(i).to_string(), q));
1016        }
1017    }
1018    Ok(Some(out))
1019}
1020
1021/// Blocking wrappers.
1022pub fn write_flows_blocking(uri: &str, rows: &[TextRow]) -> Result<u64> {
1023    crate::rt::block_on(write_flows(uri, rows))
1024}
1025pub fn read_flows_blocking(uri: &str) -> Result<Option<Vec<TextRow>>> {
1026    crate::rt::block_on(read_flows(uri))
1027}
1028
1029/// Persist the inferred repo style guide (single-row `style_guide` table).
1030/// Persist one style guide per repo as `(repo, content)` rows.
1031pub async fn write_style_guide(uri: &str, rows: &[(String, String)]) -> Result<()> {
1032    let schema = Arc::new(Schema::new(vec![
1033        Field::new("repo", DataType::Utf8, false),
1034        Field::new("content", DataType::Utf8, false),
1035    ]));
1036    let repos: Vec<&str> = rows.iter().map(|(r, _)| r.as_str()).collect();
1037    let contents: Vec<&str> = rows.iter().map(|(_, c)| c.as_str()).collect();
1038    let batch = RecordBatch::try_new(
1039        schema,
1040        vec![
1041            Arc::new(StringArray::from(repos)) as ArrayRef,
1042            Arc::new(StringArray::from(contents)) as ArrayRef,
1043        ],
1044    )
1045    .context("build style_guide batch")?;
1046    let db = crate::storage::connect(uri).execute().await?;
1047    write_versioned(&db, "style_guide", batch).await?;
1048    Ok(())
1049}
1050
1051/// Read the persisted per-repo style guides as `(repo, content)` pairs.
1052pub async fn read_style_guide(uri: &str) -> Result<Vec<(String, String)>> {
1053    let db = crate::storage::connect(uri).execute().await?;
1054    let Ok(tbl) = db.open_table("style_guide").execute().await else {
1055        return Ok(vec![]);
1056    };
1057    let mut st = tbl.query().execute().await?;
1058    let mut out = Vec::new();
1059    while let Some(b) = st.try_next().await? {
1060        let repo = str_col(&b, "repo")?;
1061        let content = str_col(&b, "content")?;
1062        for i in 0..b.num_rows() {
1063            out.push((repo.value(i).to_string(), content.value(i).to_string()));
1064        }
1065    }
1066    Ok(out)
1067}
1068
1069/// Blocking wrapper for [`write_style_guide`].
1070pub fn write_style_guide_blocking(uri: &str, rows: &[(String, String)]) -> Result<()> {
1071    crate::rt::block_on(write_style_guide(uri, rows))
1072}
1073
1074/// Blocking wrapper for [`read_style_guide`].
1075pub fn read_style_guide_blocking(uri: &str) -> Result<Vec<(String, String)>> {
1076    crate::rt::block_on(read_style_guide(uri))
1077}
1078
1079/// Persist per-file parse facts (JSON per file) so incremental runs can re-resolve everything.
1080pub async fn write_facts(uri: &str, facts: &[FileFacts]) -> Result<()> {
1081    let schema = Arc::new(Schema::new(vec![
1082        Field::new("repo", DataType::Utf8, false),
1083        Field::new("path", DataType::Utf8, false),
1084        Field::new("facts", DataType::Utf8, false),
1085    ]));
1086    let json: Vec<String> = facts
1087        .iter()
1088        .map(serde_json::to_string)
1089        .collect::<Result<_, _>>()
1090        .context("serialize facts")?;
1091    let batch = RecordBatch::try_new(
1092        schema,
1093        vec![
1094            Arc::new(StringArray::from_iter_values(
1095                facts.iter().map(|f| f.repo.as_str()),
1096            )) as ArrayRef,
1097            Arc::new(StringArray::from_iter_values(
1098                facts.iter().map(|f| f.path.as_str()),
1099            )),
1100            Arc::new(StringArray::from(json)),
1101        ],
1102    )
1103    .context("build file_facts batch")?;
1104    let db = crate::storage::connect(uri).execute().await?;
1105    write_versioned(&db, "file_facts", batch).await?;
1106    Ok(())
1107}
1108
1109/// Read persisted facts; `None` for indexes written before facts were stored.
1110pub async fn read_facts(uri: &str) -> Result<Option<Vec<FileFacts>>> {
1111    let db = crate::storage::connect(uri).execute().await?;
1112    let Ok(tbl) = db.open_table("file_facts").execute().await else {
1113        return Ok(None);
1114    };
1115    let mut st = tbl.query().execute().await?;
1116    let mut out = Vec::new();
1117    while let Some(b) = st.try_next().await? {
1118        let col = str_col(&b, "facts")?;
1119        for i in 0..b.num_rows() {
1120            out.push(serde_json::from_str(col.value(i)).context("parse facts row")?);
1121        }
1122    }
1123    Ok(Some(out))
1124}
1125
1126/// Persist derived conventions (e.g. history recipes), JSON per row.
1127pub async fn write_recipes(uri: &str, rows: &[crate::conventions::Convention]) -> Result<()> {
1128    let schema = Arc::new(Schema::new(vec![
1129        Field::new("repo", DataType::Utf8, false),
1130        Field::new("row", DataType::Utf8, false),
1131    ]));
1132    let json: Vec<String> = rows
1133        .iter()
1134        .map(serde_json::to_string)
1135        .collect::<Result<_, _>>()
1136        .context("serialize recipes")?;
1137    let batch = RecordBatch::try_new(
1138        schema,
1139        vec![
1140            Arc::new(StringArray::from_iter_values(
1141                rows.iter().map(|r| r.repo.as_str()),
1142            )) as ArrayRef,
1143            Arc::new(StringArray::from(json)),
1144        ],
1145    )
1146    .context("build recipes batch")?;
1147    let db = crate::storage::connect(uri).execute().await?;
1148    write_versioned(&db, "recipes", batch).await?;
1149    Ok(())
1150}
1151
1152pub async fn read_recipes(uri: &str) -> Result<Vec<crate::conventions::Convention>> {
1153    let db = crate::storage::connect(uri).execute().await?;
1154    let Ok(tbl) = db.open_table("recipes").execute().await else {
1155        return Ok(Vec::new());
1156    };
1157    let mut st = tbl.query().execute().await?;
1158    let mut out = Vec::new();
1159    while let Some(b) = st.try_next().await? {
1160        let col = str_col(&b, "row")?;
1161        for i in 0..b.num_rows() {
1162            out.push(serde_json::from_str(col.value(i)).context("parse recipe row")?);
1163        }
1164    }
1165    Ok(out)
1166}
1167
1168pub fn write_recipes_blocking(uri: &str, rows: &[crate::conventions::Convention]) -> Result<()> {
1169    crate::rt::block_on(write_recipes(uri, rows))
1170}
1171
1172pub fn read_recipes_blocking(uri: &str) -> Result<Vec<crate::conventions::Convention>> {
1173    crate::rt::block_on(read_recipes(uri))
1174}
1175
1176/// Persist manifest/lockfile package rows (JSON per row).
1177pub async fn write_packages(uri: &str, rows: &[crate::manifests::PackageRow]) -> Result<()> {
1178    let schema = Arc::new(Schema::new(vec![
1179        Field::new("repo", DataType::Utf8, false),
1180        Field::new("row", DataType::Utf8, false),
1181    ]));
1182    let json: Vec<String> = rows
1183        .iter()
1184        .map(serde_json::to_string)
1185        .collect::<Result<_, _>>()
1186        .context("serialize packages")?;
1187    let batch = RecordBatch::try_new(
1188        schema,
1189        vec![
1190            Arc::new(StringArray::from_iter_values(
1191                rows.iter().map(|r| r.repo.as_str()),
1192            )) as ArrayRef,
1193            Arc::new(StringArray::from(json)),
1194        ],
1195    )
1196    .context("build packages batch")?;
1197    let db = crate::storage::connect(uri).execute().await?;
1198    write_versioned(&db, "packages", batch).await?;
1199    Ok(())
1200}
1201
1202pub async fn read_packages(uri: &str) -> Result<Vec<crate::manifests::PackageRow>> {
1203    let db = crate::storage::connect(uri).execute().await?;
1204    let Ok(tbl) = db.open_table("packages").execute().await else {
1205        return Ok(Vec::new());
1206    };
1207    let mut st = tbl.query().execute().await?;
1208    let mut out = Vec::new();
1209    while let Some(b) = st.try_next().await? {
1210        let col = str_col(&b, "row")?;
1211        for i in 0..b.num_rows() {
1212            out.push(serde_json::from_str(col.value(i)).context("parse package row")?);
1213        }
1214    }
1215    Ok(out)
1216}
1217
1218pub fn write_packages_blocking(uri: &str, rows: &[crate::manifests::PackageRow]) -> Result<()> {
1219    crate::rt::block_on(write_packages(uri, rows))
1220}
1221
1222pub fn read_packages_blocking(uri: &str) -> Result<Vec<crate::manifests::PackageRow>> {
1223    crate::rt::block_on(read_packages(uri))
1224}
1225
1226pub fn write_facts_blocking(uri: &str, facts: &[FileFacts]) -> Result<()> {
1227    crate::rt::block_on(write_facts(uri, facts))
1228}
1229
1230pub fn read_facts_blocking(uri: &str) -> Result<Option<Vec<FileFacts>>> {
1231    crate::rt::block_on(read_facts(uri))
1232}
1233
1234// ---- file contents of the indexed commits -----------------------------------------------
1235
1236/// Store `files`: contents go into `source_blobs` once per hash (append-only, so unchanged files
1237/// cost nothing), and `source_files` maps each path to its hash. With `merge`, rows of
1238/// repositories `files` does not cover are kept. Returns the files and the new contents written.
1239pub async fn write_sources(
1240    uri: &str,
1241    files: &[crate::sources::SourceFile],
1242    merge: bool,
1243) -> Result<(usize, usize)> {
1244    use std::collections::HashSet;
1245    let db = crate::storage::connect(uri).execute().await?;
1246    let blobs = db.open_table("source_blobs").execute().await.ok();
1247    let mut have: HashSet<String> = HashSet::new();
1248    if let Some(t) = &blobs {
1249        let mut st = t
1250            .query()
1251            .select(Select::columns(&["hash"]))
1252            .execute()
1253            .await?;
1254        while let Some(b) = st.try_next().await? {
1255            let h = str_col(&b, "hash")?;
1256            have.extend((0..b.num_rows()).map(|i| h.value(i).to_string()));
1257        }
1258    }
1259    let mut fresh: Vec<&crate::sources::SourceFile> = Vec::new();
1260    for f in files {
1261        if have.insert(f.hash.clone()) {
1262            fresh.push(f);
1263        }
1264    }
1265    if !fresh.is_empty() {
1266        let batch = RecordBatch::try_new(
1267            Arc::new(Schema::new(vec![
1268                Field::new("hash", DataType::Utf8, false),
1269                Field::new("content", DataType::Utf8, false),
1270            ])),
1271            vec![
1272                Arc::new(StringArray::from_iter_values(fresh.iter().map(|f| &f.hash))) as ArrayRef,
1273                Arc::new(StringArray::from_iter_values(
1274                    fresh.iter().map(|f| &f.content),
1275                )) as ArrayRef,
1276            ],
1277        )?;
1278        let tbl = match blobs {
1279            Some(t) => {
1280                t.add(batch).mode(AddDataMode::Append).execute().await?;
1281                t
1282            }
1283            None => db.create_table("source_blobs", batch).execute().await?,
1284        };
1285        tbl.create_index(&["hash"], Index::BTree(Default::default()))
1286            .replace(true)
1287            .execute()
1288            .await
1289            .context("index source_blobs")?;
1290    }
1291
1292    let covered: HashSet<&str> = files.iter().map(|f| f.repo.as_str()).collect();
1293    let mut rows: Vec<(String, String, String)> = if merge {
1294        read_source_files(uri)
1295            .await
1296            .unwrap_or_default()
1297            .into_iter()
1298            .filter(|(r, _, _)| !covered.contains(r.as_str()))
1299            .collect()
1300    } else {
1301        Vec::new()
1302    };
1303    rows.extend(
1304        files
1305            .iter()
1306            .map(|f| (f.repo.clone(), f.path.clone(), f.hash.clone())),
1307    );
1308    let batch = RecordBatch::try_new(
1309        Arc::new(Schema::new(vec![
1310            Field::new("repo", DataType::Utf8, false),
1311            Field::new("path", DataType::Utf8, false),
1312            Field::new("hash", DataType::Utf8, false),
1313        ])),
1314        vec![
1315            Arc::new(StringArray::from_iter_values(rows.iter().map(|r| &r.0))) as ArrayRef,
1316            Arc::new(StringArray::from_iter_values(rows.iter().map(|r| &r.1))) as ArrayRef,
1317            Arc::new(StringArray::from_iter_values(rows.iter().map(|r| &r.2))) as ArrayRef,
1318        ],
1319    )?;
1320    write_versioned(&db, "source_files", batch).await?;
1321    Ok((files.len(), fresh.len()))
1322}
1323
1324/// Every stored `(repo, path, hash)`.
1325pub async fn read_source_files(uri: &str) -> Result<Vec<(String, String, String)>> {
1326    let db = crate::storage::connect(uri).execute().await?;
1327    let tbl = db.open_table("source_files").execute().await?;
1328    let mut out = Vec::new();
1329    let mut st = tbl.query().execute().await?;
1330    while let Some(b) = st.try_next().await? {
1331        let (r, p, h) = (
1332            str_col(&b, "repo")?,
1333            str_col(&b, "path")?,
1334            str_col(&b, "hash")?,
1335        );
1336        for i in 0..b.num_rows() {
1337            out.push((
1338                r.value(i).to_string(),
1339                p.value(i).to_string(),
1340                h.value(i).to_string(),
1341            ));
1342        }
1343    }
1344    Ok(out)
1345}
1346
1347/// The stored contents of `path` in `repo`, if the index holds them.
1348pub async fn read_source(uri: &str, repo: &str, path: &str) -> Result<Option<String>> {
1349    let quote = |s: &str| format!("'{}'", s.replace('\'', "''"));
1350    let db = crate::storage::connect(uri).execute().await?;
1351    let Ok(files) = db.open_table("source_files").execute().await else {
1352        return Ok(None);
1353    };
1354    let mut st = files
1355        .query()
1356        .only_if(format!("repo = {} AND path = {}", quote(repo), quote(path)))
1357        .select(Select::columns(&["hash"]))
1358        .limit(1)
1359        .execute()
1360        .await?;
1361    let Some(hash) = first_str(&mut st, "hash").await? else {
1362        return Ok(None);
1363    };
1364    let blobs = db.open_table("source_blobs").execute().await?;
1365    let mut st = blobs
1366        .query()
1367        .only_if(format!("hash = {}", quote(&hash)))
1368        .select(Select::columns(&["content"]))
1369        .limit(1)
1370        .execute()
1371        .await?;
1372    first_str(&mut st, "content").await
1373}
1374
1375async fn first_str(
1376    st: &mut lancedb::arrow::SendableRecordBatchStream,
1377    col: &str,
1378) -> Result<Option<String>> {
1379    while let Some(b) = st.try_next().await? {
1380        if b.num_rows() > 0 {
1381            return Ok(Some(str_col(&b, col)?.value(0).to_string()));
1382        }
1383    }
1384    Ok(None)
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389    use super::*;
1390
1391    #[tokio::test]
1392    async fn repo_meta_gains_remotes_and_old_tables_still_read() {
1393        let dir = std::env::temp_dir().join(format!("comindly-meta-{}", std::process::id()));
1394        let uri = dir.to_str().unwrap();
1395        let old = RecordBatch::try_new(
1396            Arc::new(Schema::new(vec![
1397                Field::new("repo", DataType::Utf8, false),
1398                Field::new("commit", DataType::Utf8, false),
1399            ])),
1400            vec![
1401                Arc::new(StringArray::from(vec!["orders"])) as ArrayRef,
1402                Arc::new(StringArray::from(vec!["abc"])) as ArrayRef,
1403            ],
1404        )
1405        .unwrap();
1406        let db = crate::storage::connect(uri).execute().await.unwrap();
1407        write_versioned(&db, "repo_meta", old).await.unwrap();
1408        let read = read_indexed_repos(uri).await.unwrap();
1409        assert_eq!(read[0].remote, None);
1410        let next = IndexedRepo {
1411            name: "orders".into(),
1412            commit: "def".into(),
1413            remote: Some("https://example.com/orders.git".into()),
1414            branch: Some("main".into()),
1415        };
1416        write_repo_commits(uri, std::slice::from_ref(&next))
1417            .await
1418            .unwrap();
1419        assert_eq!(read_indexed_repos(uri).await.unwrap(), vec![next]);
1420        std::fs::remove_dir_all(&dir).ok();
1421    }
1422
1423    #[tokio::test]
1424    async fn sources_store_each_content_once_and_merge_by_repo() {
1425        use crate::sources::SourceFile;
1426        let dir = std::env::temp_dir().join(format!("comindly-sources-idx-{}", std::process::id()));
1427        let _ = std::fs::remove_dir_all(&dir);
1428        let uri = dir.to_str().unwrap();
1429        let file = |repo: &str, path: &str, content: &str| SourceFile {
1430            repo: repo.into(),
1431            path: path.into(),
1432            hash: blake3::hash(content.as_bytes()).to_hex()[..32].to_string(),
1433            content: content.into(),
1434        };
1435        let a = file("orders", "src/it's.py", "x = 1\n");
1436        let b = file("billing", "lib/b.py", "x = 1\n");
1437        assert_eq!(
1438            write_sources(uri, &[a.clone(), b], false).await.unwrap(),
1439            (2, 1)
1440        );
1441        assert_eq!(
1442            read_source(uri, "orders", "src/it's.py")
1443                .await
1444                .unwrap()
1445                .as_deref(),
1446            Some("x = 1\n")
1447        );
1448
1449        let changed = file("orders", "src/it's.py", "x = 2\n");
1450        assert_eq!(write_sources(uri, &[changed], true).await.unwrap(), (1, 1));
1451        assert_eq!(
1452            read_source(uri, "orders", "src/it's.py")
1453                .await
1454                .unwrap()
1455                .as_deref(),
1456            Some("x = 2\n")
1457        );
1458        assert_eq!(
1459            read_source(uri, "billing", "lib/b.py")
1460                .await
1461                .unwrap()
1462                .as_deref(),
1463            Some("x = 1\n")
1464        );
1465        assert_eq!(
1466            read_source(uri, "billing", "missing.py").await.unwrap(),
1467            None
1468        );
1469        std::fs::remove_dir_all(&dir).ok();
1470    }
1471}