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:
235
crates/kebab-store-sqlite/tests/asset_writer.rs
Normal file
235
crates/kebab-store-sqlite/tests/asset_writer.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
50
crates/kebab-store-sqlite/tests/common/mod.rs
Normal file
50
crates/kebab-store-sqlite/tests/common/mod.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
135
crates/kebab-store-sqlite/tests/contract_roundtrip.rs
Normal file
135
crates/kebab-store-sqlite/tests/contract_roundtrip.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
479
crates/kebab-store-sqlite/tests/fts.rs
Normal file
479
crates/kebab-store-sqlite/tests/fts.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
241
crates/kebab-store-sqlite/tests/idempotency.rs
Normal file
241
crates/kebab-store-sqlite/tests/idempotency.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
99
crates/kebab-store-sqlite/tests/ingest_report_snapshot.rs
Normal file
99
crates/kebab-store-sqlite/tests/ingest_report_snapshot.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
90
crates/kebab-store-sqlite/tests/jobs.rs
Normal file
90
crates/kebab-store-sqlite/tests/jobs.rs
Normal 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);
|
||||
}
|
||||
140
crates/kebab-store-sqlite/tests/list_docs.rs
Normal file
140
crates/kebab-store-sqlite/tests/list_docs.rs
Normal 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);
|
||||
}
|
||||
83
crates/kebab-store-sqlite/tests/migration.rs
Normal file
83
crates/kebab-store-sqlite/tests/migration.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user