Skip to content

feat(flare-docs): index per-item rustdoc docs, not just the crate overview - #323

Merged
getappz merged 4 commits into
masterfrom
task/343
Jul 25, 2026
Merged

getappz merged 4 commits into
masterfrom
task/343

Conversation

@getappz

@getappz getappz commented Jul 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • flare_docs search/get previously only indexed the crate-root docstring (extract_root_docstring); a query for e.g. axum::extract::State only hit if that exact text appeared on the crate's overview page. Found while dogfooding the tool against real crates.
  • store_fetched now also parses the already-fetched rustdoc-JSON index/paths maps and upserts one document per item that has both a docstring and a public path, via a new trimmed IndexCrate/IndexItem deserializer (crates/flare-docs/src/index_types.rs) that never materializes the (large, deeply nested) ItemEnum subtree — item kind comes from paths[id].kind instead.
  • A crate can have thousands of indexable items, so indexing them one DocsStore::upsert at a time would mean thousands of individual BEGIN IMMEDIATE transactions per fetch. Added DocsStore::upsert_batch — one transaction for a whole crate's item set.
  • The batch path surfaced two pre-existing gaps in agentflare-store's document schema that predate this per-item use case (it was designed for occasional user documents, not high-fan-out cache rows):
    • doc_upsert_with_opts unconditionally wrote a store_doc_history snapshot row on every update, even when content was byte-identical — pure write amplification for docs.rs content, which is stable across refreshes. Added DocUpsertOpts.track_history: bool (default true, so existing callers are unaffected) and a content-hash short-circuit that skips the history write when content hasn't changed.
    • No DB-level uniqueness on (project_id, path) — only the single-writer application code path enforced it. Added CREATE UNIQUE INDEX idx_docs_project_path, which the new batch path's INSERT ... ON CONFLICT upsert relies on.

Test plan

  • cargo test -p flare-docs -p agentflare-store — all new + existing tests pass
  • cargo test --workspace — 746+ tests, 0 failures
  • cargo clippy -p flare-docs -p agentflare-store --all-targets --all-features -- -D warnings -A unsafe_code -A clippy::pedantic (exact CI flags) — clean
  • cargo fmt --check — clean
  • Full-workspace clippy under the same CI flags fails only on pre-existing issues in unrelated crates (agent-registry, daemon_autostart.rs, agent_launch.rs) — confirmed via git stash that they reproduce identically on unmodified master

Summary by CodeRabbit

  • New Features
    • Rust documentation indexing now stores searchable docs for individual public items in addition to crate-level documentation.
    • Added bulk document upsert support to speed up documentation imports.
  • Bug Fixes
    • Document history snapshots are now optional and only written when content actually changes.
    • Prevents duplicate stored documents for the same project and path.
    • Improved search query handling for special embedded syntax to avoid errors.
  • Tests
    • Added coverage for history skipping, identical-content re-upserts, uniqueness enforcement, and item-doc searching.

…rview

flare_docs search/get previously only indexed the crate-root docstring, so
a query for e.g. axum::extract::State only hit if that string happened to
appear on the crate's overview page. store_fetched now also parses the
rustdoc-JSON index/paths maps and upserts one document per item that has
both a docstring and a public path, using a trimmed IndexCrate/IndexItem
deserializer that never materializes the ItemEnum subtree (item kind comes
from paths[id].kind instead).

Indexing thousands of items per crate one upsert at a time would mean
thousands of individual transactions per fetch, so DocsStore gains
upsert_batch: one BEGIN IMMEDIATE transaction for a crate's whole item set.

That batch path also exposed a couple of agentflare-store gaps that predate
this per-item use case: doc_upsert_with_opts unconditionally snapshotted a
history row on every write (pure amplification for docs.rs content, which
is stable across refreshes) and there was no DB-level uniqueness constraint
on (project_id, path), only the single-writer application code path.
DocUpsertOpts gains a track_history flag (default true, existing callers
unaffected) and doc_upsert_with_opts now skips the history snapshot when
content is unchanged; a UNIQUE INDEX on (project_id, path) backs the new
batch path's ON CONFLICT upsert.

Agentflare-Agent: claude-code_2-1-220_agent
Agentflare-Branch: task/343
Agentflare-Item: 343
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds configurable document history snapshots and unique document keys, introduces rustdoc item extraction, adds transactional batch document upserts, normalizes FTS queries, and stores searchable item documentation during rustdoc ingestion.

Changes

Documentation indexing and storage

Layer / File(s) Summary
Document upsert contracts and uniqueness
crates/agentflare-store/src/documents.rs, crates/agentflare-store/src/migrations.rs, crates/agentflare-store/Cargo.toml
DocUpsertOpts adds configurable history tracking, history writes require changed content, duplicate project-path documents are rejected, and FTS queries are normalized before matching.
Rustdoc item extraction
crates/flare-docs/Cargo.toml, crates/flare-docs/src/index_types.rs, crates/flare-docs/src/lib.rs
Adds rustdoc JSON types and converts documented public items into indexed records with paths, names, documentation, and item kinds.
Batch document persistence
crates/flare-docs/src/store.rs
Adds BatchItem and transactional batch upserts that update versions, preserve document IDs, and refresh full-text search records.
Rustdoc ingestion and indexing
crates/flare-docs/src/rustdoc.rs
Parses fetched rustdoc JSON, stores item documentation through batch upserts, and tests retrieval and searchability.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RustdocFetcher
  participant index_types
  participant DocsStore
  participant SQLite
  RustdocFetcher->>index_types: Parse rustdoc JSON index and paths
  index_types->>RustdocFetcher: Return documented indexed items
  RustdocFetcher->>DocsStore: Submit BatchItem records
  DocsStore->>SQLite: Upsert documents and refresh full-text search
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: per-item Rustdoc indexing in flare-docs.
Description check ✅ Passed The PR includes a solid Summary and Test plan, with backward-compatibility notes embedded, but the reviewer Notes section is missing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/343

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
crates/flare-docs/src/store.rs (1)

80-141: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Statements re-prepared on every loop iteration; consider prepare_cached.

The doc comment on upsert_batch explicitly calls out avoiding per-item transaction overhead for crates with "thousands of items," but the loop body still calls tx.query_row(...) / tx.execute(...) with raw SQL strings, which re-parses each of the 3 statements on every iteration (no prepare_cached). For a large rustdoc index this adds up. Hoisting to tx.prepare_cached(sql)? (rusqlite caches by SQL text) would cut re-parsing cost while keeping the single-transaction behavior.

♻️ Proposed refactor using prepare_cached
-        for item in items {
-            let tags_json = serde_json::to_string(&item.tags).unwrap_or_else(|_| "[]".into());
-            let id = db_kit::ids::new_id();
-            let rowid: i64 = tx.query_row(
-                "INSERT INTO store_documents
+        let mut upsert_stmt = tx.prepare_cached(
+            "INSERT INTO store_documents
                  (id, project_id, path, content, title, doc_type, blob_hash, mime, tags, session_id, source, metadata, size, version, created_at, updated_at)
                  VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, '', ?7, NULL, ?8, '{}', 0, 1, ?9, ?9)
                  ON CONFLICT(project_id, path) DO UPDATE SET
                     content = excluded.content,
                     title = excluded.title,
                     doc_type = excluded.doc_type,
                     tags = excluded.tags,
                     source = excluded.source,
                     version = store_documents.version + 1,
                     updated_at = excluded.updated_at,
                     deleted_at = NULL
-                 RETURNING rowid",
+                 RETURNING rowid",
+        )?;
+        let mut fts_del_stmt = tx.prepare_cached("DELETE FROM store_docs_fts WHERE rowid = ?1")?;
+        let mut fts_ins_stmt =
+            tx.prepare_cached("INSERT INTO store_docs_fts(rowid, content) VALUES (?1, ?2)")?;
+
+        for item in items {
+            let tags_json = serde_json::to_string(&item.tags).unwrap_or_else(|_| "[]".into());
+            let id = db_kit::ids::new_id();
+            let rowid: i64 = upsert_stmt.query_row(
                 rusqlite::params![
                     id,
                     PROJECT_ID,
                     item.path,
                     item.content,
                     item.title,
                     item.doc_type,
                     tags_json,
                     item.source,
                     now,
                 ],
                 |row| row.get(0),
             )?;
 
-            tx.execute(
-                "DELETE FROM store_docs_fts WHERE rowid = ?1",
-                rusqlite::params![rowid],
-            )?;
-            tx.execute(
-                "INSERT INTO store_docs_fts(rowid, content) VALUES (?1, ?2)",
-                rusqlite::params![rowid, item.content],
-            )?;
+            fts_del_stmt.execute(rusqlite::params![rowid])?;
+            fts_ins_stmt.execute(rusqlite::params![rowid, item.content])?;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/flare-docs/src/store.rs` around lines 80 - 141, Update upsert_batch to
prepare and cache the upsert, FTS delete, and FTS insert statements once before
iterating over items, using tx.prepare_cached with the existing SQL and
parameters. Reuse those prepared statements inside the loop while preserving the
current transaction, rowid handling, and return behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/agentflare-store/src/documents.rs`:
- Around line 184-196: Update the history-snapshot condition in the document
upsert flow around new_version and the optional-field UPDATEs so history is
skipped only for a true no-op, not merely when old_content equals content. When
track_history is enabled and any persisted document field changes, insert the
superseded version into store_doc_history; retain the skip behavior for
identical-content re-upserts with no other changes and for track_history being
false.

In `@crates/agentflare-store/src/migrations.rs`:
- Around line 89-91: Update the migration’s M::up sequence in migrations.rs to
remove or deterministically consolidate existing duplicate store_documents rows
grouped by (project_id, path) before creating idx_docs_project_path. Ensure the
cleanup preserves the intended row data, then create the unique index so
upsert_batch’s ON CONFLICT(project_id, path) contract works on databases with
pre-existing duplicates.

In `@crates/flare-docs/src/rustdoc.rs`:
- Around line 131-143: Replace the direct upsert-only flow around indexed_items
and store.upsert_batch with a scoped transactional reconciliation for the
current crate path: upsert the current BatchItem entries, remove document and
FTS rows under crate_id_path/item whose paths are absent from the fetched items,
and preserve atomicity. Add coverage for re-fetching latest after an item
disappears, verifying stale content is removed.

---

Nitpick comments:
In `@crates/flare-docs/src/store.rs`:
- Around line 80-141: Update upsert_batch to prepare and cache the upsert, FTS
delete, and FTS insert statements once before iterating over items, using
tx.prepare_cached with the existing SQL and parameters. Reuse those prepared
statements inside the loop while preserving the current transaction, rowid
handling, and return behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f8d13c1-570a-41f8-b701-dfba97eecbaf

📥 Commits

Reviewing files that changed from the base of the PR and between cd0b68a and 635c03e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • crates/agentflare-store/src/documents.rs
  • crates/agentflare-store/src/migrations.rs
  • crates/flare-docs/Cargo.toml
  • crates/flare-docs/src/index_types.rs
  • crates/flare-docs/src/lib.rs
  • crates/flare-docs/src/rustdoc.rs
  • crates/flare-docs/src/store.rs

Comment on lines 184 to +196

// Snapshot current version to history
tx.execute(
"INSERT INTO store_doc_history (id, doc_id, version, content, blob_hash, mime, title, metadata, size, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT title FROM store_documents WHERE id = ?2), ?7, ?8, ?9)",
params![history_id, existing_id, old_version, old_content, old_blob_hash, old_mime, old_metadata, old_size, now],
)?;
// Snapshot current version to history, unless the caller opted
// out (cache-type documents) or content is byte-identical to
// what's already stored (a no-op re-upsert has nothing to
// snapshot).
if opts.track_history && old_content != content {
let history_id = db_kit::ids::new_id();
tx.execute(
"INSERT INTO store_doc_history (id, doc_id, version, content, blob_hash, mime, title, metadata, size, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT title FROM store_documents WHERE id = ?2), ?7, ?8, ?9)",
params![history_id, existing_id, old_version, old_content, old_blob_hash, old_mime, old_metadata, old_size, now],
)?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

History snapshot skipped by content-equality creates version gaps when other fields change.

The skip condition is opts.track_history && old_content != content, but new_version = old_version + 1 (line 183) and the optional-field UPDATEs (lines 206-261) are applied unconditionally. If a caller updates only metadata, tags, title, etc. while content stays the same (with track_history: true, the default), the document's version still increments but no store_doc_history row is written for the superseded version. This leaves a permanent gap: doc_get_version(id, N) for that version returns None even though version N existed, and doc_history no longer reflects a contiguous version sequence. The new tests (identical_content_reupsert_skips_history_row, track_history_false_skips_history_row) only exercise the "nothing else changed" case, so this gap isn't caught.

🐛 Proposed fix: only skip when the call is a true no-op
-            // Snapshot current version to history, unless the caller opted
-            // out (cache-type documents) or content is byte-identical to
-            // what's already stored (a no-op re-upsert has nothing to
-            // snapshot).
-            if opts.track_history && old_content != content {
+            // Snapshot current version to history, unless the caller opted
+            // out (cache-type documents) or this call is a true no-op
+            // re-upsert (content unchanged AND no other field is being set).
+            let is_no_op_reupsert = old_content == content
+                && opts.title.is_none()
+                && opts.doc_type.is_none()
+                && opts.blob_hash.is_none()
+                && opts.mime.is_none()
+                && opts.tags.is_none()
+                && opts.session_id.is_none()
+                && opts.source.is_none()
+                && opts.metadata.is_none()
+                && opts.size.is_none();
+            if opts.track_history && !is_no_op_reupsert {
                 let history_id = db_kit::ids::new_id();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Snapshot current version to history
tx.execute(
"INSERT INTO store_doc_history (id, doc_id, version, content, blob_hash, mime, title, metadata, size, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT title FROM store_documents WHERE id = ?2), ?7, ?8, ?9)",
params![history_id, existing_id, old_version, old_content, old_blob_hash, old_mime, old_metadata, old_size, now],
)?;
// Snapshot current version to history, unless the caller opted
// out (cache-type documents) or content is byte-identical to
// what's already stored (a no-op re-upsert has nothing to
// snapshot).
if opts.track_history && old_content != content {
let history_id = db_kit::ids::new_id();
tx.execute(
"INSERT INTO store_doc_history (id, doc_id, version, content, blob_hash, mime, title, metadata, size, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT title FROM store_documents WHERE id = ?2), ?7, ?8, ?9)",
params![history_id, existing_id, old_version, old_content, old_blob_hash, old_mime, old_metadata, old_size, now],
)?;
}
// Snapshot current version to history, unless the caller opted
// out (cache-type documents) or this call is a true no-op
// re-upsert (content unchanged AND no other field is being set).
let is_no_op_reupsert = old_content == content
&& opts.title.is_none()
&& opts.doc_type.is_none()
&& opts.blob_hash.is_none()
&& opts.mime.is_none()
&& opts.tags.is_none()
&& opts.session_id.is_none()
&& opts.source.is_none()
&& opts.metadata.is_none()
&& opts.size.is_none();
if opts.track_history && !is_no_op_reupsert {
let history_id = db_kit::ids::new_id();
tx.execute(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-store/src/documents.rs` around lines 184 - 196, Update the
history-snapshot condition in the document upsert flow around new_version and
the optional-field UPDATEs so history is skipped only for a true no-op, not
merely when old_content equals content. When track_history is enabled and any
persisted document field changes, insert the superseded version into
store_doc_history; retain the skip behavior for identical-content re-upserts
with no other changes and for track_history being false.

Comment on lines +89 to +91
M::up(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_docs_project_path ON store_documents(project_id, path);",
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for any other direct INSERT INTO store_documents outside the Store API
# that could have bypassed the (project_id, path) uniqueness assumption.
ast-grep run --pattern 'INSERT INTO store_documents' --lang sql crates 2>/dev/null
rg -n --type=rust -C2 'INSERT INTO store_documents' crates

Repository: getappz/agentflare

Length of output: 1944


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files and inspect the migration + write paths.
sed -n '1,180p' crates/agentflare-store/src/migrations.rs
printf '\n--- documents.rs upsert area ---\n'
sed -n '140,210p' crates/agentflare-store/src/documents.rs
printf '\n--- documents.rs insert area ---\n'
sed -n '250,320p' crates/agentflare-store/src/documents.rs
printf '\n--- flare-docs store.rs upsert area ---\n'
sed -n '70,130p' crates/flare-docs/src/store.rs

Repository: getappz/agentflare

Length of output: 12799


Clean up existing (project_id, path) duplicates before this index lands CREATE UNIQUE INDEX IF NOT EXISTS will abort on any pre-existing duplicates, and upsert_batch depends on this constraint for ON CONFLICT(project_id, path).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-store/src/migrations.rs` around lines 89 - 91, Update the
migration’s M::up sequence in migrations.rs to remove or deterministically
consolidate existing duplicate store_documents rows grouped by (project_id,
path) before creating idx_docs_project_path. Ensure the cleanup preserves the
intended row data, then create the unique index so upsert_batch’s ON
CONFLICT(project_id, path) contract works on databases with pre-existing
duplicates.

Comment on lines +131 to +143
let batch: Vec<BatchItem> = indexed_items(&index_crate)
.into_iter()
.map(|item| BatchItem {
path: format!("{crate_id_path}/item/{}", item.fq_path),
content: item.docs,
title: item.name,
doc_type: "rust-item".to_string(),
tags: vec![crate_name.to_string(), "rust".to_string(), item.kind],
source: "docsrs".to_string(),
})
.collect();

Ok(store.upsert_batch(&batch)?)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Reconcile items removed by a later fetch.

upsert_batch only inserts or updates current paths; it never deletes prior ${crate_id_path}/item/... rows absent from indexed_items. Re-fetching latest after an item is removed, privatized, or loses docs leaves stale content in FTS. Add a scoped, transactional replace/reconciliation operation that removes stale document and FTS rows, and test a re-fetch where an item disappears.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/flare-docs/src/rustdoc.rs` around lines 131 - 143, Replace the direct
upsert-only flow around indexed_items and store.upsert_batch with a scoped
transactional reconciliation for the current crate path: upsert the current
BatchItem entries, remove document and FTS rows under crate_id_path/item whose
paths are absent from the fetched items, and preserve atomicity. Add coverage
for re-fetching latest after an item disappears, verifying stale content is
removed.

…yntax

Found live while dogfeeding the new per-item rustdoc indexing: searching
the exact fully-qualified path syntax this feature exists to make
searchable (e.g. "axum::extract::State") errored with "no such column:
axum" instead of returning results, because doc_search passed the raw
query straight to FTS5 MATCH, and FTS5 parses a bare `word:` prefix as
column-filter syntax.

flare-search-kit already has fts_phrase_query for exactly this shape of
query (a single-column table matched with a bare `MATCH ?1`, no AND/OR
structure of our own) -- skill-registry's search already depends on the
same crate's fts_query for the equivalent problem. Wires it into
doc_search instead of reinventing sanitization.

Agentflare-Agent: claude-code_2-1-220_agent
Agentflare-Branch: task/343
Agentflare-Item: 343
getappz added 2 commits July 25, 2026 17:31
store_fetched propagated index_items' error via ?, even though the crate
overview doc had already been committed by that point. A single ItemKind
variant rustdoc-types doesn't recognize fails IndexCrate's deserialization
for the entire crate's item set, which turned an otherwise-successful
fetch into a reported failure. Log and continue instead.

Also make upsert_batch skip the FTS resync and version bump when an
item's content is byte-identical to what's already stored, matching the
content-hash short-circuit doc_upsert_with_opts already has for the
single-doc path -- otherwise every refetch of an unchanged crate still
pays full per-item write cost and grows version unboundedly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant