refactor(rename): kb crates → kebab — Cargo packages, folders, Rust modules

프로젝트 이름 `kb` → `kebab` rename 의 첫 단계.

- workspace `Cargo.toml`: members `crates/kb-*` → `crates/kebab-*`,
  repository URL `altair823/kb` → `altair823/kebab`.
- 18 crate 폴더 rename via `git mv` (history 보존).
- 각 crate `Cargo.toml`: `name = "kb-*"` → `"kebab-*"`, path deps
  `../kb-*` → `../kebab-*`.
- 모든 `.rs`: `kb_<id>` snake-case 모듈 path 18 개 (`kb_core`,
  `kb_config`, `kb_app`, `kb_cli`, `kb_eval`, `kb_search`, `kb_chunk`,
  `kb_normalize`, `kb_source_fs`, `kb_parse_md`, `kb_parse_types`,
  `kb_store_sqlite`, `kb_store_vector`, `kb_embed`, `kb_embed_local`,
  `kb_llm`, `kb_llm_local`, `kb_rag`) → `kebab_<id>` 일괄 sed (단어
  경계 \\b 사용해 영어 문장 안의 "kb" 약어 미오염).

CLI binary 이름 (`[[bin]] name = "kb"`), 환경변수 `KB_*`, XDG paths,
tracing target, 그리고 docs sweep 은 다음 commit 에서.

## 검증

- `cargo check --workspace` clean — 모든 crate 빌드 통과 후 commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 03:28:08 +00:00
parent 2aecbf3d9f
commit 911fb49550
143 changed files with 727 additions and 727 deletions

View File

@@ -0,0 +1,235 @@
//! Asset writer tests: copy mode (file written 0o644), reference mode
//! (no copy, row records source), and checksum mismatch (Conflict).
use std::path::PathBuf;
use kebab_core::{AssetId, AssetStorage, Checksum, MediaType, RawAsset, SourceUri, WorkspacePath};
use kebab_store_sqlite::SqliteStore;
use time::OffsetDateTime;
mod common;
fn fixed_asset(_bytes: &[u8], byte_len: u64, declared_checksum: &str) -> RawAsset {
RawAsset {
// 32-hex AssetId per kb-core newtype invariant.
asset_id: AssetId("a".repeat(32)),
source_uri: SourceUri::File(PathBuf::from("/some/source.md")),
workspace_path: WorkspacePath::new("notes/foo.md".into()).unwrap(),
media_type: MediaType::Markdown,
byte_len,
checksum: Checksum(declared_checksum.into()),
discovered_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
stored: AssetStorage::Reference {
path: PathBuf::from("/some/source.md"),
sha: Checksum("0".repeat(64)),
},
}
}
fn b3_full_hex(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
#[test]
fn copy_mode_writes_file_with_0o644_and_correct_bytes() {
let env = common::TestEnv::with_threshold(100);
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let bytes = b"hello, sqlite";
let cs = b3_full_hex(bytes);
let asset = fixed_asset(bytes, bytes.len() as u64, &cs);
store.put_asset_with_bytes(&asset, bytes).expect("write");
// Path: data_dir/assets/aa/aaaaaa…aa
let aa = &asset.asset_id.0[..2];
let dest = env.data_dir().join("assets").join(aa).join(&asset.asset_id.0);
assert!(dest.exists(), "asset file not written at {}", dest.display());
let on_disk = std::fs::read(&dest).unwrap();
assert_eq!(on_disk, bytes);
// Mode 0o644 on Unix.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o644, "expected 0o644, got 0o{mode:o}");
}
// Row recorded copied.
let storage_kind: String = env.with_conn(|c| {
c.query_row(
"SELECT storage_kind FROM assets WHERE asset_id = ?",
[&asset.asset_id.0],
|r| r.get(0),
)
});
assert_eq!(storage_kind, "copied");
}
#[test]
fn reference_mode_does_not_write_file_but_records_path() {
// copy_threshold_mb=0 → every byte lands on the reference branch.
let env = common::TestEnv::with_threshold(0);
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let bytes = b"big-pretend-bytes";
let cs = b3_full_hex(bytes);
// byte_len declared > 0 so the threshold check picks reference. With
// copy_threshold_bytes=0 even byte_len=1 trips the else branch.
let mut asset = fixed_asset(bytes, 1, &cs);
asset.source_uri = SourceUri::File(PathBuf::from("/path/to/original.md"));
store.put_asset_with_bytes(&asset, bytes).expect("ref write");
let aa = &asset.asset_id.0[..2];
let dest = env.data_dir().join("assets").join(aa).join(&asset.asset_id.0);
assert!(!dest.exists(), "reference mode must not copy bytes");
let (storage_kind, storage_path): (String, String) = env.with_conn(|c| {
c.query_row(
"SELECT storage_kind, storage_path FROM assets WHERE asset_id = ?",
[&asset.asset_id.0],
|r| Ok((r.get(0)?, r.get(1)?)),
)
});
assert_eq!(storage_kind, "reference");
assert_eq!(storage_path, "/path/to/original.md");
}
#[test]
fn put_asset_with_bytes_orphan_cleanup_on_upsert_failure() {
// Goal: prove that if the row UPSERT fails AFTER the bytes have been
// staged on disk, no `<aa>/<asset_id>` file is left behind.
//
// Lever: the `assets` table has a UNIQUE INDEX on `workspace_path`
// (V001), but the UPSERT is `ON CONFLICT(asset_id)`. So if some other
// row already owns this `workspace_path`, the INSERT half of the
// UPSERT trips a UNIQUE constraint that the ON CONFLICT clause does
// NOT handle — UPSERT errors. The new asset's bytes were already
// staged; we assert they are NOT visible at the final destination.
let env = common::TestEnv::with_threshold(100);
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
// Pre-populate a row that owns `notes/foo.md` (the workspace_path our
// fixture asset will also claim) under a *different* asset_id.
env.with_conn(|c| {
c.execute(
"INSERT INTO assets (
asset_id, source_uri, workspace_path, media_type, byte_len,
checksum, storage_kind, storage_path, discovered_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
"b".repeat(32),
"file:///elsewhere/foo.md",
"notes/foo.md",
"\"markdown\"",
7_i64,
"0".repeat(64),
"reference",
"/elsewhere/foo.md",
"2024-01-01T00:00:00Z",
],
)
});
let bytes = b"hello, sqlite";
let cs = b3_full_hex(bytes);
let asset = fixed_asset(bytes, bytes.len() as u64, &cs);
let err = store
.put_asset_with_bytes(&asset, bytes)
.expect_err("UPSERT must fail on workspace_path UNIQUE violation");
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("unique") || msg.to_lowercase().contains("constraint"),
"expected UNIQUE constraint failure, got: {msg}"
);
// Final destination must NOT exist (no orphan).
let aa = &asset.asset_id.0[..2];
let dest = env.data_dir().join("assets").join(aa).join(&asset.asset_id.0);
assert!(
!dest.exists(),
"asset bytes were left orphan at {} after UPSERT failure",
dest.display()
);
// No `*.tmp.*` either — temp file must be cleaned up too.
let shard_dir = env.data_dir().join("assets").join(aa);
if let Ok(entries) = std::fs::read_dir(&shard_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let s = name.to_string_lossy();
assert!(
!s.contains(".tmp."),
"temp file leaked at {}",
entry.path().display()
);
}
}
}
#[test]
fn put_asset_with_bytes_rejects_invalid_asset_id() {
// `kebab_core::AssetId(pub String)` lets a hand-construction bypass the
// 32-hex `FromStr` invariant. The store boundary must reject any ID
// whose shape would let path construction escape `data_dir/assets/`.
let env = common::TestEnv::with_threshold(100);
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
// 32 chars but contains a `/` — would let `assets_path_for` stitch
// together a path outside the shard tree.
let evil_id = "../etc/passwd_padded_to_xx_xxxxx".to_string();
assert_eq!(evil_id.len(), 32, "test fixture must be 32 chars to exercise length-only checks");
let mut asset = fixed_asset(b"x", 1, &b3_full_hex(b"x"));
asset.asset_id = AssetId(evil_id.clone());
let err = store
.put_asset_with_bytes(&asset, b"x")
.expect_err("must reject non-hex AssetId");
let msg = format!("{err:#}");
assert!(
msg.contains("invalid AssetId shape"),
"expected AssetId-shape rejection, got: {msg}"
);
// And the bytes must NOT have been staged anywhere under the assets
// tree (no I/O should have happened before validation).
let assets_dir = env.data_dir().join("assets");
if assets_dir.exists() {
for entry in std::fs::read_dir(&assets_dir).unwrap().flatten() {
// Recurse one level into shard dirs and assert empty.
if let Some(sub) = std::fs::read_dir(entry.path()).unwrap().flatten().next() {
panic!(
"invalid AssetId still produced filesystem artifact at {}",
sub.path().display()
);
}
}
}
}
#[test]
fn checksum_mismatch_returns_conflict() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let bytes = b"the real bytes";
// Tampered checksum: hash a different payload.
let wrong_cs = b3_full_hex(b"different bytes");
let asset = fixed_asset(bytes, bytes.len() as u64, &wrong_cs);
let err = store
.put_asset_with_bytes(&asset, bytes)
.expect_err("must reject checksum mismatch");
let msg = format!("{err:#}");
assert!(
msg.contains("checksum mismatch") || msg.contains("conflict"),
"expected Conflict-flavoured error, got: {msg}"
);
}

View File

@@ -0,0 +1,50 @@
//! Shared test scaffolding: temp data_dir + freshly opened SqliteStore.
#![allow(dead_code)]
use std::path::PathBuf;
use kebab_config::Config;
use rusqlite::Connection;
use tempfile::TempDir;
pub struct TestEnv {
pub temp: TempDir,
pub config: Config,
}
impl TestEnv {
pub fn new() -> Self {
Self::with_threshold(100)
}
/// Override the copy-threshold (useful for the reference-mode test
/// where we want a small file to land on the reference branch).
pub fn with_threshold(copy_threshold_mb: u64) -> Self {
let temp = tempfile::tempdir().expect("tempdir");
let mut config = Config::defaults();
config.storage.data_dir = temp.path().to_string_lossy().into_owned();
config.storage.copy_threshold_mb = copy_threshold_mb;
Self { temp, config }
}
pub fn config(&self) -> Config {
self.config.clone()
}
pub fn data_dir(&self) -> PathBuf {
self.temp.path().to_path_buf()
}
pub fn db_path(&self) -> PathBuf {
self.temp.path().join("kb.sqlite")
}
/// Open a side-channel rusqlite connection for direct SQL inspection.
/// The store-owned connection is held inside a Mutex; opening a fresh
/// one is the simplest way for tests to peek at row counts / pragmas.
pub fn with_conn<T>(&self, f: impl FnOnce(&Connection) -> rusqlite::Result<T>) -> T {
let conn = Connection::open(self.db_path()).expect("open side conn");
f(&conn).expect("with_conn closure")
}
}

View File

@@ -0,0 +1,135 @@
//! Contract: drive the full pipeline (`kb-parse-md` → `kb-normalize` →
//! `kb-chunk`) on a real fixture and prove `DocumentStore` round-trips
//! the resulting `CanonicalDocument` + `Vec<Chunk>` losslessly.
//!
//! `kb-parse-md`, `kb-normalize`, `kb-chunk` are dev-deps only — see the
//! crate's `Cargo.toml`. The store crate's production tree (visible via
//! `cargo tree -p kb-store-sqlite --depth 1`) does NOT include them.
use std::path::PathBuf;
use kebab_chunk::MdHeadingV1Chunker;
use kebab_core::{
AssetId, AssetStorage, Checksum, ChunkPolicy, ChunkerVersion, Chunker, DocumentStore,
MediaType, ParserVersion, RawAsset, SourceUri, WorkspacePath,
};
use kebab_normalize::build_canonical_document;
use kebab_parse_md::{BodyHints, parse_blocks, parse_frontmatter};
use kebab_store_sqlite::SqliteStore;
use time::OffsetDateTime;
mod common;
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("markdown")
}
#[test]
fn document_and_chunks_round_trip_through_sqlite() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
// ── Build inputs from the fixture ───────────────────────────────
let dir = fixtures_dir();
let bytes = std::fs::read(dir.join("code-and-table.md")).expect("read fixture");
let cs = blake3::hash(&bytes).to_hex().to_string();
let asset = RawAsset {
asset_id: AssetId("a".repeat(32)),
source_uri: SourceUri::File(dir.join("code-and-table.md")),
workspace_path: WorkspacePath::new("notes/code-and-table.md".into()).unwrap(),
media_type: MediaType::Markdown,
byte_len: bytes.len() as u64,
checksum: Checksum(cs.clone()),
discovered_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
stored: AssetStorage::Reference {
path: dir.join("code-and-table.md"),
sha: Checksum(cs.clone()),
},
};
let hints = BodyHints {
first_h1: Some("Code And Table".into()),
fs_ctime: asset.discovered_at,
fs_mtime: asset.discovered_at,
fallback_lang: Some("en".into()),
};
let (mut metadata, _fm_span, _fm_warns) =
parse_frontmatter(&bytes, &hints).unwrap();
let (parsed_blocks, parse_warns) = parse_blocks(&bytes, 1).unwrap();
metadata.aliases.sort();
metadata.tags.sort();
let parser_version = ParserVersion("kb-store-sqlite-roundtrip".into());
let doc = build_canonical_document(
&asset,
metadata,
parsed_blocks,
&parser_version,
parse_warns,
)
.unwrap();
let policy = ChunkPolicy {
target_tokens: 200,
overlap_tokens: 40,
respect_markdown_headings: true,
chunker_version: ChunkerVersion("md-heading-v1".into()),
};
let chunks = MdHeadingV1Chunker.chunk(&doc, &policy).unwrap();
assert!(!chunks.is_empty(), "fixture must produce ≥1 chunk");
// ── Persist via the store ────────────────────────────────────────
store
.put_asset_with_bytes(&asset, &bytes)
.expect("put_asset_with_bytes");
store.put_document(&doc).expect("put_document");
store
.put_blocks(&doc.doc_id, &doc.blocks)
.expect("put_blocks");
store
.put_chunks(&doc.doc_id, &chunks)
.expect("put_chunks");
// ── Read back ────────────────────────────────────────────────────
let loaded = store
.get_document(&doc.doc_id)
.expect("get_document err")
.expect("get_document Some");
// Document-level fields must match. doc_version is bumped by the
// UPSERT path even on first put (the trigger runs on conflict
// only; first-insert lands the caller-supplied 1). updated_at is
// re-stamped server-side and is NOT round-tripped (the loaded
// CanonicalDocument carries `metadata.updated_at` from the
// metadata_json blob, which is the input value). So we compare
// the field-by-field copies that ARE deterministic:
assert_eq!(loaded.doc_id, doc.doc_id);
assert_eq!(loaded.workspace_path, doc.workspace_path);
assert_eq!(loaded.title, doc.title);
assert_eq!(loaded.lang, doc.lang);
assert_eq!(loaded.parser_version, doc.parser_version);
assert_eq!(loaded.schema_version, doc.schema_version);
assert_eq!(loaded.metadata, doc.metadata, "metadata round-trip");
assert_eq!(loaded.provenance, doc.provenance, "provenance round-trip");
assert_eq!(
loaded.blocks.len(),
doc.blocks.len(),
"block count round-trip"
);
assert_eq!(loaded.blocks, doc.blocks, "block stream round-trip");
// Chunks: get_chunk for each id.
for c in &chunks {
let back = store
.get_chunk(&c.chunk_id)
.expect("get_chunk err")
.expect("get_chunk Some");
assert_eq!(&back, c, "chunk round-trip mismatch for {}", c.chunk_id.0);
}
}

View File

@@ -0,0 +1,479 @@
//! P2-1 FTS5 schema + trigger + rebuild tests.
//!
//! Strategy: `chunks_fts` triggers fire off raw SQL on `chunks`, so we
//! seed and mutate via direct INSERT/UPDATE/DELETE rather than the full
//! `kb-parse-md → kb-normalize → kb-chunk → put_chunks` pipeline. That
//! keeps the assertions about trigger behavior independent of any
//! upstream crate. The `chunks` rows we produce satisfy NOT NULL on the
//! columns required by V001 §5.5; we elide FK pressure on `documents`
//! by disabling foreign keys for the test connection (the trigger logic
//! we exercise has no `documents` dependency).
//!
//! Test connections open a fresh side-channel `rusqlite::Connection`
//! that bypasses the `SqliteStore` mutex; that's fine because each test
//! gets its own tempdir and no concurrent mutator is in flight.
use kebab_store_sqlite::{SqliteStore, rebuild_chunks_fts};
use rusqlite::Connection;
mod common;
/// Insert a chunks row directly. The triggers will mirror it into
/// `chunks_fts` as part of the same statement.
fn insert_chunk(
conn: &Connection,
chunk_id: &str,
doc_id: &str,
heading_path_json: &str,
text: &str,
) {
conn.execute(
"INSERT INTO chunks (
chunk_id, doc_id, text, heading_path_json, section_label,
source_spans_json, token_estimate, chunker_version,
policy_hash, block_ids_json, created_at
) VALUES (?, ?, ?, ?, NULL, '[]', 0, 'v1', 'h', '[]', '2024-01-01T00:00:00Z')",
rusqlite::params![chunk_id, doc_id, text, heading_path_json],
)
.expect("insert chunk row");
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.expect("count")
}
/// Open a fresh side-channel connection with FK enforcement OFF. The
/// FTS triggers we test do not touch `documents`, but `chunks` has a
/// FK to `documents(doc_id)`; turning FK enforcement off lets us seed
/// chunks without first synthesizing a full documents/assets row graph.
fn raw_conn_no_fk(env: &common::TestEnv) -> Connection {
let conn = Connection::open(env.db_path()).expect("open side conn");
conn.pragma_update(None, "foreign_keys", "OFF").unwrap();
conn
}
// ── 1. Migration apply: backfill ──────────────────────────────────────
/// Apply V001 only, seed N rows into `chunks` (which has no FTS shadow
/// at this point — V001 doesn't create `chunks_fts`), then apply V002's
/// SQL verbatim. The V002 backfill INSERT must produce one chunks_fts
/// row per pre-existing chunks row, and each row's columns must match.
///
/// This is the literal cold-upgrade path: V001-shipped database, V002
/// applied on top, existing chunks become searchable without re-ingest.
/// The trigger-based mirror (chunks_ai) is covered by the §2 tests.
#[test]
fn fts_v002_backfills_existing_chunks() {
let env = common::TestEnv::new();
let conn = Connection::open(env.db_path()).expect("open db");
conn.pragma_update(None, "foreign_keys", "OFF").unwrap();
// 1) Apply V001 only — chunks table exists, chunks_fts does not.
let v001_sql = include_str!("../../../migrations/V001__init.sql");
conn.execute_batch(v001_sql).expect("apply V001");
assert!(
conn.query_row(
"SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_fts'",
[],
|r| r.get::<_, String>(0),
)
.is_err(),
"chunks_fts must not exist under V001 only"
);
// 2) Seed pre-existing chunks rows (the V001-shipped state we expect
// on a customer DB upgrading from P1 to P2-1).
const N: usize = 4;
for i in 0..N {
let cid = format!("{:0>32}", i);
insert_chunk(
&conn,
&cid,
&"d".repeat(32),
"[\"Section\"]",
&format!("seedrow{i} payload"),
);
}
assert_eq!(count(&conn, "chunks"), N as i64);
// 3) Apply V002 verbatim — its CREATE VIRTUAL TABLE + triggers + the
// final backfill INSERT. The triggers don't fire on this path
// (they only fire on chunks INSERT/UPDATE/DELETE); the backfill
// INSERT does the work.
let v002_sql = include_str!("../../../migrations/V002__fts.sql");
conn.execute_batch(v002_sql).expect("apply V002");
// 4) Assert: count parity, and the backfilled rows mirror the chunks
// rows column-for-column on the indexed/UNINDEXED columns.
assert_eq!(
count(&conn, "chunks_fts"),
N as i64,
"V002 backfill INSERT must seed one chunks_fts row per chunks row"
);
for i in 0..N {
let cid = format!("{:0>32}", i);
let term = format!("seedrow{i}");
let hit: String = conn
.query_row(
"SELECT chunk_id FROM chunks_fts WHERE chunks_fts MATCH ?",
[&term],
|r| r.get(0),
)
.unwrap_or_else(|_| panic!("MATCH {term} must hit backfilled row"));
assert_eq!(hit, cid, "backfill must preserve chunk_id mapping");
}
}
/// Direct test of the V002 backfill INSERT on a DB seeded under V001.
/// We achieve V001-only state by running all migrations, dropping the
/// FTS rows, then re-running the exact backfill INSERT V002 ships and
/// asserting count parity.
#[test]
fn fts_v002_backfill_select_matches_chunks_count() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let conn = raw_conn_no_fk(&env);
for i in 0..5 {
let cid = format!("{:0>32}", i);
insert_chunk(&conn, &cid, &"d".repeat(32), "[]", &format!("row {i}"));
}
// Wipe + run the literal V002 backfill INSERT.
conn.execute("DELETE FROM chunks_fts", []).unwrap();
assert_eq!(count(&conn, "chunks_fts"), 0);
conn.execute(
"INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text)
SELECT chunk_id, doc_id, heading_path_json, text FROM chunks",
[],
)
.unwrap();
assert_eq!(count(&conn, "chunks_fts"), count(&conn, "chunks"));
}
// ── 2. Trigger sync: INSERT / DELETE / UPDATE ────────────────────────
#[test]
fn fts_chunks_ai_trigger_propagates_insert() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let conn = raw_conn_no_fk(&env);
insert_chunk(
&conn,
&"a".repeat(32),
&"d".repeat(32),
"[\"Heading\"]",
"needle in haystack",
);
// chunks_fts row count == 1 and MATCH finds it.
assert_eq!(count(&conn, "chunks_fts"), 1);
let hit: String = conn
.query_row(
"SELECT chunk_id FROM chunks_fts WHERE chunks_fts MATCH 'needle'",
[],
|r| r.get(0),
)
.expect("MATCH 'needle' must hit");
assert_eq!(hit, "a".repeat(32));
}
#[test]
fn fts_chunks_ad_trigger_propagates_delete() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let conn = raw_conn_no_fk(&env);
let cid = "a".repeat(32);
insert_chunk(&conn, &cid, &"d".repeat(32), "[]", "ephemeral");
assert_eq!(count(&conn, "chunks_fts"), 1);
conn.execute("DELETE FROM chunks WHERE chunk_id = ?", [&cid])
.expect("delete chunk");
assert_eq!(
count(&conn, "chunks_fts"),
0,
"chunks_ad must remove the FTS row"
);
}
#[test]
fn fts_chunks_au_trigger_propagates_update() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let conn = raw_conn_no_fk(&env);
let cid = "a".repeat(32);
insert_chunk(&conn, &cid, &"d".repeat(32), "[]", "before");
// Old text is searchable.
assert_eq!(count_match(&conn, "before"), 1);
assert_eq!(count_match(&conn, "after"), 0);
conn.execute(
"UPDATE chunks SET text = ? WHERE chunk_id = ?",
rusqlite::params!["after rewrite", cid],
)
.expect("update chunk text");
// New text is searchable; old token is gone. Row count unchanged.
assert_eq!(count(&conn, "chunks_fts"), 1);
assert_eq!(
count_match(&conn, "before"),
0,
"old text must not survive UPDATE"
);
assert_eq!(count_match(&conn, "after"), 1, "new text must be indexed");
}
fn count_match(conn: &Connection, term: &str) -> i64 {
conn.query_row(
"SELECT COUNT(*) FROM chunks_fts WHERE chunks_fts MATCH ?",
[term],
|r| r.get(0),
)
.expect("count_match")
}
// ── 3. rebuild_chunks_fts ────────────────────────────────────────────
#[test]
fn fts_rebuild_chunks_fts_is_idempotent() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let conn = raw_conn_no_fk(&env);
for i in 0..3 {
let cid = format!("{:0>32}", i);
insert_chunk(&conn, &cid, &"d".repeat(32), "[]", &format!("token{i}"));
}
let before = count(&conn, "chunks_fts");
assert_eq!(before, 3);
// First rebuild: trivial round-trip — same row count.
rebuild_chunks_fts(&conn).expect("rebuild 1");
assert_eq!(count(&conn, "chunks_fts"), before);
// Second rebuild: idempotent (same row count again).
rebuild_chunks_fts(&conn).expect("rebuild 2");
assert_eq!(count(&conn, "chunks_fts"), before);
// After rebuild, MATCH still finds expected tokens.
for i in 0..3 {
assert_eq!(count_match(&conn, &format!("token{i}")), 1);
}
}
#[test]
fn fts_rebuild_chunks_fts_recovers_from_drift() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let conn = raw_conn_no_fk(&env);
let cid = "a".repeat(32);
insert_chunk(&conn, &cid, &"d".repeat(32), "[]", "recovered");
// Manually wipe chunks_fts to simulate drift; this is the failure
// mode `kb index --rebuild-fts` exists to recover from.
conn.execute("DELETE FROM chunks_fts", []).unwrap();
assert_eq!(count(&conn, "chunks_fts"), 0);
assert_eq!(count(&conn, "chunks"), 1);
rebuild_chunks_fts(&conn).expect("rebuild");
assert_eq!(count(&conn, "chunks_fts"), 1);
assert_eq!(count_match(&conn, "recovered"), 1);
}
// ── 4. Migration double-apply no-op ──────────────────────────────────
#[test]
fn fts_double_run_migrations_is_noop() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().expect("run 1");
// Second invocation must be a no-op (refinery's bookkeeping table
// tracks applied versions). The chunks_fts virtual table is still
// present and queryable.
store.run_migrations().expect("run 2");
let conn = raw_conn_no_fk(&env);
// The virtual table is queryable.
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM chunks_fts", [], |r| r.get(0))
.expect("chunks_fts queryable after double-run");
assert_eq!(n, 0);
}
// ── 5. CI diff guard: V002 SQL matches design §5.5 verbatim ──────────
/// Whitespace-normalize a SQL block: trim, then collapse every run of
/// whitespace (newlines included) into a single space. Lets the
/// design-doc ↔ migration-file comparison ignore cosmetic drift like
/// blank-line counts while still catching token-level changes.
fn normalize_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Extract the §5.5 FTS slice from the design doc: locate the
/// `### 5.5 Chunks + FTS5` heading, walk to the next ```sql fenced
/// block, then within that block slice from `CREATE VIRTUAL TABLE
/// chunks_fts` through the last `END;`. The §5.5 fenced block also
/// contains the `chunks` CREATE TABLE — we only want the FTS portion.
///
/// Failure modes (any of these means the design doc layout drifted —
/// the test should fail loud, which is the point):
/// - heading missing
/// - no ```sql block follows
/// - no `CREATE VIRTUAL TABLE chunks_fts` inside that block
/// - no `END;` after the virtual-table line
fn extract_design_5_5_fts_block() -> String {
let doc = include_str!(
"../../../docs/superpowers/specs/2026-04-27-kb-final-form-design.md"
);
let heading_idx = doc
.find("### 5.5 Chunks + FTS5")
.expect("design doc must contain `### 5.5 Chunks + FTS5` heading");
let after_heading = &doc[heading_idx..];
// Find the opening fence ```sql after the heading.
let fence_open_rel = after_heading
.find("```sql")
.expect("§5.5 must be followed by a ```sql fenced block");
// Move past the fence line.
let body_start_rel = fence_open_rel
+ after_heading[fence_open_rel..]
.find('\n')
.expect("```sql fence must end with a newline")
+ 1;
let body = &after_heading[body_start_rel..];
let fence_close_rel = body
.find("\n```")
.expect("§5.5 ```sql block must close with ``` on its own line");
let fenced = &body[..fence_close_rel];
// Within the fenced block, slice from CREATE VIRTUAL TABLE chunks_fts
// through the last `END;`.
let virt_idx = fenced
.find("CREATE VIRTUAL TABLE chunks_fts")
.expect("§5.5 fenced block must contain `CREATE VIRTUAL TABLE chunks_fts`");
let fts_slice = &fenced[virt_idx..];
let last_end = fts_slice
.rfind("END;")
.expect("§5.5 FTS slice must terminate with `END;`");
fts_slice[..last_end + "END;".len()].to_string()
}
/// Extract the §5.5 verbatim block from the V002 migration, between the
/// `── §5.5 verbatim block ──` anchor markers the file already carries.
fn extract_migration_5_5_verbatim_block() -> String {
let migration = include_str!("../../../migrations/V002__fts.sql");
// The opening anchor line ends with `── §5.5 verbatim block ─...`.
let open_marker = "§5.5 verbatim block";
let close_marker = "End §5.5 verbatim block";
let open_idx = migration
.find(open_marker)
.expect("V002 must carry the `§5.5 verbatim block` opening anchor");
let after_open_line = open_idx
+ migration[open_idx..]
.find('\n')
.expect("opening anchor line must end with a newline")
+ 1;
let close_idx = migration[after_open_line..]
.find(close_marker)
.expect("V002 must carry the `End §5.5 verbatim block` closing anchor")
+ after_open_line;
// Walk back from the close marker to the start of its comment line.
let close_line_start = migration[..close_idx]
.rfind('\n')
.map(|n| n + 1)
.unwrap_or(0);
migration[after_open_line..close_line_start].to_string()
}
/// CI diff guard: the §5.5 block in `migrations/V002__fts.sql` must
/// match the design doc verbatim (whitespace-normalized). If the
/// design doc moves the section, renames the heading, or edits the
/// SQL, this test fails first. Same for migration drift.
#[test]
fn fts_v002_matches_design_section_5_5_verbatim() {
let design = extract_design_5_5_fts_block();
let migration_block = extract_migration_5_5_verbatim_block();
// Sanity: the slices we extracted look like the §5.5 FTS block (not
// some unrelated snippet that happened to match a marker).
assert!(
design.contains("CREATE VIRTUAL TABLE chunks_fts"),
"design slice must include CREATE VIRTUAL TABLE chunks_fts"
);
assert!(
migration_block.contains("CREATE VIRTUAL TABLE chunks_fts"),
"migration slice must include CREATE VIRTUAL TABLE chunks_fts"
);
assert!(
design.trim_end().ends_with("END;"),
"design slice must terminate with END;"
);
let design_n = normalize_ws(&design);
let migration_n = normalize_ws(&migration_block);
assert_eq!(
design_n, migration_n,
"V002__fts.sql §5.5 block must match design doc §5.5 verbatim \
(whitespace-normalized). If you intentionally changed one, \
update the other in the same commit."
);
}
// ── 6. WAL cleanup: drop store before tempdir reaps WAL/SHM ──────────
/// Mirror the P1-6 pattern: opening + migrating + dropping the store
/// must not strand `kb.sqlite-wal`/`-shm` files such that the tempdir
/// can't be cleaned up. After dropping the store + side-channel conn,
/// the WAL/SHM siblings must either not exist or be removable — if a
/// stray handle were holding them open, on Windows the remove would
/// fail (on Linux unlink succeeds even with open handles, so this is
/// mostly a portability canary, but we still assert).
#[test]
fn fts_store_drop_releases_wal_files() {
let env = common::TestEnv::new();
let db_path = env.db_path();
{
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
// Force at least one trigger fire so WAL has content to flush.
let conn = raw_conn_no_fk(&env);
insert_chunk(&conn, &"a".repeat(32), &"d".repeat(32), "[]", "x");
drop(conn);
drop(store);
}
// After the store drops, any remaining WAL/SHM siblings must be
// removable. If a connection is still open this would fail on
// platforms with mandatory file locking.
for suffix in ["-wal", "-shm"] {
let p = db_path.with_extension(format!("sqlite{suffix}"));
if p.exists() {
std::fs::remove_file(&p).unwrap_or_else(|e| {
panic!(
"WAL/SHM sibling {} should be removable after store drop: {e}",
p.display()
)
});
}
}
// The main DB file should likewise be removable.
if db_path.exists() {
std::fs::remove_file(&db_path)
.expect("main DB file should be removable after store drop");
}
}

View File

@@ -0,0 +1,241 @@
//! Idempotency: re-ingesting the same `(workspace_path, asset_id,
//! parser_version)` keeps documents at one row but bumps `doc_version`
//! and replaces blocks/chunks rather than duplicating them.
use std::path::PathBuf;
use kebab_core::{
AssetId, AssetStorage, Block, CanonicalDocument, Checksum, Chunk, ChunkerVersion,
CommonBlock, DocumentId, DocumentStore, HeadingBlock, Lang, MediaType, Metadata,
ParserVersion, Provenance, RawAsset, SourceSpan, SourceType, SourceUri, TextBlock,
TrustLevel, WorkspacePath,
};
use kebab_store_sqlite::SqliteStore;
use time::OffsetDateTime;
mod common;
fn make_asset() -> RawAsset {
let bytes = b"dummy";
RawAsset {
asset_id: AssetId("a".repeat(32)),
source_uri: SourceUri::File(PathBuf::from("/tmp/foo.md")),
workspace_path: WorkspacePath::new("notes/foo.md".into()).unwrap(),
media_type: MediaType::Markdown,
byte_len: bytes.len() as u64,
checksum: Checksum(blake3::hash(bytes).to_hex().to_string()),
discovered_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
stored: AssetStorage::Reference {
path: PathBuf::from("/tmp/foo.md"),
sha: Checksum(blake3::hash(bytes).to_hex().to_string()),
},
}
}
fn make_metadata() -> Metadata {
Metadata {
aliases: vec![],
tags: vec!["one".into(), "two".into()],
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
source_type: SourceType::Markdown,
trust_level: TrustLevel::Primary,
user_id_alias: None,
user: Default::default(),
}
}
fn make_doc() -> CanonicalDocument {
let doc_id = DocumentId("d".repeat(32));
let span = SourceSpan::Line { start: 1, end: 1 };
let block = Block::Heading(HeadingBlock {
common: CommonBlock {
block_id: kebab_core::BlockId("b".repeat(32)),
heading_path: vec![],
source_span: span.clone(),
},
level: 1,
text: "Title".into(),
});
let para = Block::Paragraph(TextBlock {
common: CommonBlock {
block_id: kebab_core::BlockId("c".repeat(32)),
heading_path: vec!["Title".into()],
source_span: span,
},
text: "body".into(),
inlines: vec![],
});
CanonicalDocument {
doc_id,
source_asset_id: AssetId("a".repeat(32)),
workspace_path: WorkspacePath::new("notes/foo.md".into()).unwrap(),
title: "Title".into(),
lang: Lang("en".into()),
blocks: vec![block, para],
metadata: make_metadata(),
provenance: Provenance { events: vec![] },
parser_version: ParserVersion("test-parser".into()),
schema_version: 1,
doc_version: 1,
}
}
fn make_chunks(doc_id: &DocumentId) -> Vec<Chunk> {
vec![Chunk {
chunk_id: kebab_core::ChunkId("e".repeat(32)),
doc_id: doc_id.clone(),
block_ids: vec![kebab_core::BlockId("b".repeat(32))],
text: "Title\n\nbody".into(),
heading_path: vec!["Title".into()],
source_spans: vec![SourceSpan::Line { start: 1, end: 1 }],
token_estimate: 5,
chunker_version: ChunkerVersion("md-heading-v1".into()),
policy_hash: "deadbeefdeadbeef".into(),
}]
}
#[test]
fn put_document_idempotent_bumps_doc_version() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let asset = make_asset();
store.put_asset(&asset).expect("put_asset 1");
let doc = make_doc();
store.put_document(&doc).expect("put_document 1");
// First ingest → exactly one row, doc_version=1.
let (count, dv1): (i64, i64) = env.with_conn(|c| {
c.query_row(
"SELECT COUNT(*), MAX(doc_version) FROM documents WHERE doc_id = ?",
[&doc.doc_id.0],
|r| Ok((r.get(0)?, r.get(1)?)),
)
});
assert_eq!(count, 1);
assert_eq!(dv1, 1);
// Re-ingest the same doc → still one row, doc_version=2.
store.put_document(&doc).expect("put_document 2");
let (count2, dv2): (i64, i64) = env.with_conn(|c| {
c.query_row(
"SELECT COUNT(*), MAX(doc_version) FROM documents WHERE doc_id = ?",
[&doc.doc_id.0],
|r| Ok((r.get(0)?, r.get(1)?)),
)
});
assert_eq!(count2, 1, "second put must not duplicate the row");
assert_eq!(dv2, 2, "doc_version must increment on re-ingest");
// Tags were re-derived: still exactly the two original tags.
let tags: Vec<String> = env.with_conn(|c| {
let mut stmt =
c.prepare("SELECT tag FROM document_tags WHERE doc_id = ? ORDER BY tag")?;
let rows = stmt.query_map([&doc.doc_id.0], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()
});
assert_eq!(tags, vec!["one".to_string(), "two".to_string()]);
}
#[test]
fn put_blocks_and_put_chunks_replace_not_duplicate() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let asset = make_asset();
store.put_asset(&asset).unwrap();
let doc = make_doc();
store.put_document(&doc).unwrap();
store.put_blocks(&doc.doc_id, &doc.blocks).unwrap();
store.put_chunks(&doc.doc_id, &make_chunks(&doc.doc_id)).unwrap();
let (b1, ch1): (i64, i64) = env.with_conn(|c| {
Ok((
c.query_row(
"SELECT COUNT(*) FROM blocks WHERE doc_id = ?",
[&doc.doc_id.0],
|r| r.get(0),
)?,
c.query_row(
"SELECT COUNT(*) FROM chunks WHERE doc_id = ?",
[&doc.doc_id.0],
|r| r.get(0),
)?,
))
});
assert_eq!(b1, 2);
assert_eq!(ch1, 1);
// Re-put same data → counts unchanged (DELETE-then-INSERT).
store.put_blocks(&doc.doc_id, &doc.blocks).unwrap();
store.put_chunks(&doc.doc_id, &make_chunks(&doc.doc_id)).unwrap();
let (b2, ch2): (i64, i64) = env.with_conn(|c| {
Ok((
c.query_row(
"SELECT COUNT(*) FROM blocks WHERE doc_id = ?",
[&doc.doc_id.0],
|r| r.get(0),
)?,
c.query_row(
"SELECT COUNT(*) FROM chunks WHERE doc_id = ?",
[&doc.doc_id.0],
|r| r.get(0),
)?,
))
});
assert_eq!(b2, 2, "blocks must not double on re-put");
assert_eq!(ch2, 1, "chunks must not double on re-put");
}
/// `put_blocks` runs in a transaction. If we feed it a block whose
/// `doc_id` references a document that does not exist, the FK
/// constraint (`blocks.doc_id REFERENCES documents(doc_id)`) trips,
/// the transaction rolls back, and the table count is unchanged.
#[test]
fn put_blocks_transactional_rollback_on_fk_violation() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let asset = make_asset();
store.put_asset(&asset).unwrap();
let doc = make_doc();
store.put_document(&doc).unwrap();
// Establish a baseline row in `blocks`.
store.put_blocks(&doc.doc_id, &doc.blocks).unwrap();
let baseline: i64 = env.with_conn(|c| {
c.query_row("SELECT COUNT(*) FROM blocks", [], |r| r.get(0))
});
assert_eq!(baseline, 2);
// Now ask put_blocks to write to a doc_id that does NOT exist.
// The implementation issues `DELETE FROM blocks WHERE doc_id = ?`
// (no-op for the missing doc) followed by INSERTs that violate the
// FK constraint. The whole tx must roll back, so `blocks` count
// stays at `baseline`.
let phantom = DocumentId("0".repeat(32));
let phantom_blocks = vec![Block::Heading(HeadingBlock {
common: CommonBlock {
block_id: kebab_core::BlockId("9".repeat(32)),
heading_path: vec![],
source_span: SourceSpan::Line { start: 1, end: 1 },
},
level: 1,
text: "phantom".into(),
})];
let res = store.put_blocks(&phantom, &phantom_blocks);
assert!(res.is_err(), "FK violation must surface as Err");
let after: i64 = env.with_conn(|c| {
c.query_row("SELECT COUNT(*) FROM blocks", [], |r| r.get(0))
});
assert_eq!(
after, baseline,
"transaction must roll back; blocks count must be unchanged"
);
}

View File

@@ -0,0 +1,99 @@
//! Snapshot test pinning the JSON wire form of `kebab_core::IngestReport`
//! for an inline fixture run. The store crate doesn't (yet) write
//! IngestReports — that's `kb-app`'s job — but the wire schema lives in
//! `kb-core`, and we want a determinism pin that fails loudly if the
//! shape drifts.
//!
//! Set `UPDATE_SNAPSHOTS=1` to re-bake the baseline.
use std::path::PathBuf;
use kebab_core::{
AssetId, ChunkerVersion, DocumentId, IngestItem, IngestItemKind, IngestReport,
ParserVersion, SourceScope, WorkspacePath,
};
use serde_json::Value;
fn baseline_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("snapshots")
.join("ingest_report.snapshot.json")
}
fn fixture_report() -> IngestReport {
IngestReport {
scope: SourceScope {
root: PathBuf::from("/home/u/KB"),
include: vec!["**/*.md".into()],
exclude: vec![".git/**".into()],
},
scanned: 3,
new: 2,
updated: 1,
skipped: 0,
errors: 0,
duration_ms: 187,
items: Some(vec![
IngestItem {
kind: IngestItemKind::New,
doc_id: Some(DocumentId("a".repeat(32))),
doc_path: WorkspacePath::new("notes/alpha.md".into()).unwrap(),
asset_id: Some(AssetId("a".repeat(32))),
byte_len: Some(1234),
block_count: Some(7),
chunk_count: Some(3),
parser_version: Some(ParserVersion("pulldown-cmark-0.x".into())),
chunker_version: Some(ChunkerVersion("md-heading-v1".into())),
warnings: vec![],
error: None,
},
IngestItem {
kind: IngestItemKind::Updated,
doc_id: Some(DocumentId("b".repeat(32))),
doc_path: WorkspacePath::new("notes/beta.md".into()).unwrap(),
asset_id: Some(AssetId("b".repeat(32))),
byte_len: Some(2048),
block_count: Some(12),
chunk_count: Some(5),
parser_version: Some(ParserVersion("pulldown-cmark-0.x".into())),
chunker_version: Some(ChunkerVersion("md-heading-v1".into())),
warnings: vec!["malformed frontmatter".into()],
error: None,
},
]),
}
}
#[test]
fn ingest_report_wire_form_is_stable() {
let report = fixture_report();
let actual = serde_json::to_value(&report).unwrap();
let baseline = match std::fs::read_to_string(baseline_path()) {
Ok(s) => s,
Err(_) if std::env::var("UPDATE_SNAPSHOTS").is_ok() => {
std::fs::create_dir_all(baseline_path().parent().unwrap()).unwrap();
let pretty = serde_json::to_string_pretty(&actual).unwrap();
std::fs::write(baseline_path(), format!("{pretty}\n")).unwrap();
return;
}
Err(e) => panic!(
"missing baseline {}; run with UPDATE_SNAPSHOTS=1: {e}",
baseline_path().display()
),
};
let expected: Value = serde_json::from_str(&baseline).unwrap();
if actual != expected {
if std::env::var("UPDATE_SNAPSHOTS").is_ok() {
let pretty = serde_json::to_string_pretty(&actual).unwrap();
std::fs::write(baseline_path(), format!("{pretty}\n")).unwrap();
return;
}
let pretty = serde_json::to_string_pretty(&actual).unwrap();
panic!(
"ingest_report snapshot drift\n\
--- expected ({}) ---\n{baseline}\n\
--- actual ---\n{pretty}",
baseline_path().display()
);
}
}

View File

@@ -0,0 +1,90 @@
//! `JobRepo` smoke tests: create → progress → finish, list filters.
use kebab_core::{JobFilter, JobKind, JobRepo, JobStatus};
use kebab_store_sqlite::SqliteStore;
use serde_json::json;
mod common;
#[test]
fn create_then_progress_then_finish() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let id = store
.create(JobKind::Ingest, json!({"path": "notes/x.md"}))
.unwrap();
// Status starts pending.
let row = store.list(&JobFilter::default()).unwrap();
assert_eq!(row.len(), 1);
assert_eq!(row[0].status, JobStatus::Pending);
// First progress flips pending → running.
store
.update_progress(&id, json!({"processed": 1, "total": 10}))
.unwrap();
let row = store.list(&JobFilter::default()).unwrap();
assert_eq!(row[0].status, JobStatus::Running);
assert_eq!(row[0].progress.as_ref().unwrap()["total"], json!(10));
// Finish with success.
store
.finish(&id, JobStatus::Succeeded, None)
.unwrap();
let row = store.list(&JobFilter::default()).unwrap();
assert_eq!(row[0].status, JobStatus::Succeeded);
assert!(row[0].finished_at.is_some());
assert!(row[0].error.is_none());
}
#[test]
fn finish_with_error_message_is_round_trippable() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
let id = store.create(JobKind::Embed, json!({})).unwrap();
store
.finish(&id, JobStatus::Failed, Some("boom: model not pulled"))
.unwrap();
let row = store.list(&JobFilter::default()).unwrap();
assert_eq!(row[0].status, JobStatus::Failed);
assert_eq!(
row[0].error.as_deref(),
Some("boom: model not pulled"),
"error message must round-trip"
);
}
#[test]
fn list_filters_status_and_kind() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
// Two ingest jobs (one finished succeeded, one pending) + one embed.
let a = store.create(JobKind::Ingest, json!({"a": 1})).unwrap();
let _b = store.create(JobKind::Ingest, json!({"b": 1})).unwrap();
let _c = store.create(JobKind::Embed, json!({"c": 1})).unwrap();
store.finish(&a, JobStatus::Succeeded, None).unwrap();
let by_status_succeeded = store
.list(&JobFilter {
status: Some(JobStatus::Succeeded),
kind: None,
})
.unwrap();
assert_eq!(by_status_succeeded.len(), 1);
assert_eq!(by_status_succeeded[0].kind, JobKind::Ingest);
let by_kind_embed = store
.list(&JobFilter {
status: None,
kind: Some(JobKind::Embed),
})
.unwrap();
assert_eq!(by_kind_embed.len(), 1);
assert_eq!(by_kind_embed[0].kind, JobKind::Embed);
}

View File

@@ -0,0 +1,140 @@
//! `DocumentStore::list_documents` filter coverage.
use std::path::PathBuf;
use kebab_core::{
AssetId, AssetStorage, Block, CanonicalDocument, Checksum, CommonBlock, DocFilter,
DocumentId, DocumentStore, HeadingBlock, Lang, MediaType, Metadata, ParserVersion,
Provenance, RawAsset, SourceSpan, SourceType, SourceUri, TrustLevel, WorkspacePath,
};
use kebab_store_sqlite::SqliteStore;
use time::OffsetDateTime;
mod common;
fn make_doc(
suffix: char,
workspace_path: &str,
lang: &str,
tags: Vec<&str>,
trust: TrustLevel,
) -> (RawAsset, CanonicalDocument) {
let bytes: Vec<u8> = vec![suffix as u8; 16];
let cs = blake3::hash(&bytes).to_hex().to_string();
let asset_id = AssetId(format!("{suffix}").repeat(32));
let asset = RawAsset {
asset_id: asset_id.clone(),
source_uri: SourceUri::File(PathBuf::from(format!("/tmp/{suffix}.md"))),
workspace_path: WorkspacePath::new(workspace_path.into()).unwrap(),
media_type: MediaType::Markdown,
byte_len: bytes.len() as u64,
checksum: Checksum(cs.clone()),
discovered_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
stored: AssetStorage::Reference {
path: PathBuf::from(format!("/tmp/{suffix}.md")),
sha: Checksum(cs),
},
};
let doc_id = DocumentId(format!("d{suffix}").repeat(16));
let block = Block::Heading(HeadingBlock {
common: CommonBlock {
block_id: kebab_core::BlockId(format!("b{suffix}").repeat(16)),
heading_path: vec![],
source_span: SourceSpan::Line { start: 1, end: 1 },
},
level: 1,
text: format!("Title {suffix}"),
});
let metadata = Metadata {
aliases: vec![],
tags: tags.into_iter().map(String::from).collect(),
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
source_type: SourceType::Markdown,
trust_level: trust,
user_id_alias: None,
user: Default::default(),
};
let doc = CanonicalDocument {
doc_id,
source_asset_id: asset_id,
workspace_path: asset.workspace_path.clone(),
title: format!("Title {suffix}"),
lang: Lang(lang.into()),
blocks: vec![block],
metadata,
provenance: Provenance { events: vec![] },
parser_version: ParserVersion("test".into()),
schema_version: 1,
doc_version: 1,
};
(asset, doc)
}
#[test]
fn list_documents_filters_lang_and_tags() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).unwrap();
store.run_migrations().unwrap();
for (asset, doc) in [
make_doc('a', "notes/a.md", "en", vec!["rust", "kb"], TrustLevel::Primary),
make_doc('b', "notes/b.md", "ko", vec!["rust"], TrustLevel::Secondary),
make_doc('c', "papers/c.md", "en", vec!["bio"], TrustLevel::Generated),
] {
store.put_asset(&asset).unwrap();
store.put_document(&doc).unwrap();
}
// No filter → all three docs.
let all = store.list_documents(&DocFilter::default()).unwrap();
assert_eq!(all.len(), 3);
// lang filter.
let en = store
.list_documents(&DocFilter {
lang: Some(Lang("en".into())),
..Default::default()
})
.unwrap();
assert_eq!(en.len(), 2);
assert!(en.iter().all(|d| d.lang == Lang("en".into())));
// path glob.
let papers = store
.list_documents(&DocFilter {
path_glob: Some("papers/*.md".into()),
..Default::default()
})
.unwrap();
assert_eq!(papers.len(), 1);
assert_eq!(papers[0].doc_path.0, "papers/c.md");
// tags_any.
let rust = store
.list_documents(&DocFilter {
tags_any: vec!["rust".into()],
..Default::default()
})
.unwrap();
assert_eq!(rust.len(), 2);
// tags must be hydrated on the result.
for d in &rust {
assert!(
d.tags.iter().any(|t| t == "rust"),
"expected `rust` tag on {}: {:?}",
d.doc_path.0,
d.tags
);
}
// trust_min — Primary only.
let primary = store
.list_documents(&DocFilter {
trust_min: Some(TrustLevel::Primary),
..Default::default()
})
.unwrap();
assert_eq!(primary.len(), 1);
assert_eq!(primary[0].trust_level, TrustLevel::Primary);
}

View File

@@ -0,0 +1,83 @@
//! Migration test: a fresh DB, after `run_migrations`, exposes every
//! table and index P1 needs (per §5.1§5.7).
use kebab_store_sqlite::SqliteStore;
mod common;
#[test]
fn fresh_db_has_all_p1_tables_and_indexes() {
let env = common::TestEnv::new();
let store = SqliteStore::open(&env.config()).expect("open");
store.run_migrations().expect("run migrations");
// Pull the list of user tables from sqlite_master.
let tables: Vec<String> = env.with_conn(|c| {
let mut stmt = c.prepare(
"SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let tables: rusqlite::Result<Vec<String>> = rows.collect();
tables
});
let required = [
"answers",
"assets",
"blocks",
"chunks",
"document_tags",
"documents",
"embedding_records",
"eval_query_results",
"eval_runs",
"ingest_runs",
"jobs",
// refinery's own bookkeeping table (`refinery_schema_history`)
// also lands here; we don't pin it but it's expected.
"migrations",
"schema_meta",
];
for t in required {
assert!(
tables.iter().any(|n| n == t),
"table `{t}` missing; got {tables:?}"
);
}
// Pin the documented indexes (subset that matters for hot paths).
let indexes: Vec<String> = env.with_conn(|c| {
let mut stmt = c.prepare(
"SELECT name FROM sqlite_master
WHERE type = 'index' AND name NOT LIKE 'sqlite_%'
ORDER BY name",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let idx: rusqlite::Result<Vec<String>> = rows.collect();
idx
});
for i in [
"idx_assets_workspace_path",
"idx_assets_media_type",
"idx_docs_workspace_path",
"idx_docs_lang",
"idx_docs_source_type",
"idx_document_tags_tag",
"idx_blocks_doc_id",
"idx_chunks_doc_id",
"idx_chunks_chunker_version",
"idx_embed_chunk",
"idx_embed_model",
"idx_jobs_status",
"idx_jobs_kind",
"idx_answers_created_at",
"idx_answers_grounded",
] {
assert!(
indexes.iter().any(|n| n == i),
"index `{i}` missing; got {indexes:?}"
);
}
}