From 5f2bd9e97e36b5de044391750d46df889078ae34 Mon Sep 17 00:00:00 2001 From: altair823 Date: Wed, 20 May 2026 07:38:10 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(dogfood):=20kebab=20reset=20--orphans-?= =?UTF-8?q?only=20=E2=80=94=20purge=20stored=20docs=20outside=20walker=20s?= =?UTF-8?q?cope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #148 auto-purges only filesystem-missing files (conservative — leaves on-disk-but-out-of-scope docs alone for data safety). This is the explicit complement: when the user has narrowed include / widened exclude / removed a sub-directory from the workspace and WANTS the stored docs reconciled, they invoke 'kebab reset --orphans-only'. Confirm prompt with orphan count + sample paths; --yes required in non-TTY. SQLite purge via existing purge_deleted_workspace_path (PR #148) + vector store delete_by_chunk_ids when configured. No fs existence check — orphans-only is the explicit 'I know what I'm doing' variant. dogfood follow-up to PR #148 (file deletion auto-purge). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-app/src/lib.rs | 2 +- crates/kebab-app/src/reset.rs | 194 ++++++++++++++++++++++++ crates/kebab-app/tests/reset_orphans.rs | 141 +++++++++++++++++ crates/kebab-cli/src/main.rs | 88 +++++++++++ crates/kebab-cli/src/wire.rs | 2 + 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 crates/kebab-app/tests/reset_orphans.rs diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index 8995149..0a991dc 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -71,7 +71,7 @@ mod staleness; pub use app::{App, SearchResponse}; pub use ingest_progress::{AggregateCounts, IngestEvent, render_skipped_breakdown}; -pub use reset::{ResetReport, ResetScope}; +pub use reset::{ResetReport, ResetScope, enumerate_orphans}; pub use error_wire::{ERROR_V1_ID, ErrorV1, StructuredError, classify}; pub use fetch::fetch_with_config; #[doc(hidden)] diff --git a/crates/kebab-app/src/reset.rs b/crates/kebab-app/src/reset.rs index 5247fed..a9cfc27 100644 --- a/crates/kebab-app/src/reset.rs +++ b/crates/kebab-app/src/reset.rs @@ -9,13 +9,19 @@ //! //! `--vector-only` additionally truncates `embedding_records` in SQLite //! so the next `kebab ingest` re-embeds cleanly without orphan rows. +//! +//! `--orphans-only` purges stored docs that are outside the current walker +//! scope (config narrowing / removed sub-directory). No filesystem paths are +//! removed — this is purely a store-level reconciliation. +use std::collections::HashSet; use std::path::PathBuf; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use kebab_config::{Config, expand_path}; +use kebab_core::WorkspacePath; /// What the user asked to remove. Mutually exclusive — picked by the CLI /// from a clap `ArgGroup`. @@ -32,6 +38,13 @@ pub enum ResetScope { VectorOnly, /// Wipe only the config dir. ConfigOnly, + /// Purge stored docs that are outside the current walker scope (no + /// filesystem paths are removed). Filesystem existence is NOT checked — + /// anything the current walker would not visit is considered an orphan. + /// The explicit complement to the conservative `sweep_deleted_files` + /// that runs during ingest (which leaves on-disk-but-out-of-scope docs + /// alone for data safety). + OrphansOnly, } /// Result of a successful wipe — emitted as `reset_report.v1` by the @@ -41,6 +54,16 @@ pub struct ResetReport { pub scope: ResetScope, pub removed_paths: Vec, pub embedding_rows_truncated: u64, + /// Number of stored docs purged because they are outside the current + /// walker scope. Non-zero only when `scope == OrphansOnly`. + /// `#[serde(default)]` preserves back-compat with older callers that + /// do not include this field. + #[serde(default)] + pub orphans_purged: u32, + /// Paths of the orphaned docs that were purged. Sorted for deterministic + /// output. Non-empty only when `scope == OrphansOnly`. + #[serde(default)] + pub purged_paths: Vec, } /// Compute the absolute on-disk paths a given scope will wipe, given a @@ -67,6 +90,10 @@ pub fn enumerate_paths(scope: ResetScope, cfg: &Config) -> Vec { vec![vector_dir] } ResetScope::ConfigOnly => vec![cfg_dir], + // OrphansOnly operates purely at the store level — no filesystem paths + // are removed. Return empty so `estimate_size_bytes` stays zero and + // the existing confirm UI path for directory wipes is skipped. + ResetScope::OrphansOnly => vec![], } } @@ -96,16 +123,82 @@ pub fn estimate_size_bytes(paths: &[PathBuf]) -> u64 { paths.iter().map(|p| walk(p)).sum() } +/// Compute the workspace paths stored in SQLite that are NOT visited by +/// the current walker scope (i.e. they are "orphans" — on disk but +/// outside the configured include/exclude rules, or from a sub-directory +/// that has since been removed from the workspace). +/// +/// Does NOT check filesystem existence — `OrphansOnly` is the explicit +/// "I know what I'm doing" variant; callers that want the conservative +/// fs-aware sweep should use `sweep_deleted_files` inside ingest. +/// +/// Returns the list sorted for deterministic output. Called twice by the +/// CLI path (once for the confirm UI preview, once inside `execute`); +/// the double scan is acceptable for a rare destructive operation. +pub fn enumerate_orphans(cfg: &Config) -> Result> { + use kebab_core::DocumentStore as _; + use kebab_source_fs::FsSourceConnector; + use kebab_core::SourceScope; + + let store = kebab_store_sqlite::SqliteStore::open(cfg) + .context("enumerate_orphans: open SqliteStore")?; + + let stored = store + .all_workspace_paths() + .context("enumerate_orphans: all_workspace_paths")?; + + if stored.is_empty() { + return Ok(Vec::new()); + } + + // Build the same SourceScope the CLI's ingest path uses: root from + // config, exclude list from config, no include override (full scope). + let root = cfg.resolve_workspace_root(); + let scope = SourceScope { + root: root.clone(), + exclude: cfg.workspace.exclude.clone(), + ..Default::default() + }; + + let connector = FsSourceConnector::new(cfg) + .context("enumerate_orphans: build FsSourceConnector")?; + let (assets, _skips) = connector + .scan_with_skips(&scope) + .context("enumerate_orphans: scan workspace")?; + + let scanned: HashSet = assets + .into_iter() + .map(|a| a.workspace_path) + .collect(); + + let mut orphans: Vec = stored + .into_iter() + .filter(|p| !scanned.contains(p)) + .collect(); + orphans.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(orphans) +} + /// Wipe every path from `enumerate_paths(scope, cfg)`. For /// `ResetScope::VectorOnly`, also truncates the SQLite /// `embedding_records` table so the store doesn't point at the Lance /// rows we just removed off-disk. /// +/// For `ResetScope::OrphansOnly`, no filesystem directories are removed. +/// Instead the store is reconciled: stored docs outside the current walker +/// scope are purged from SQLite (+ vector store when configured). The +/// caller is expected to have already shown the confirm UI using +/// `enumerate_orphans`. +/// /// Idempotent: a missing path is treated as already-removed (success). /// Returns a `ResetReport` listing exactly what was removed (paths that /// existed before the call) so `--json` callers see the truth, not the /// request. pub fn execute(scope: ResetScope, cfg: &Config) -> Result { + if matches!(scope, ResetScope::OrphansOnly) { + return execute_orphans_only(cfg); + } + let paths = enumerate_paths(scope, cfg); let mut removed = Vec::new(); @@ -128,9 +221,100 @@ pub fn execute(scope: ResetScope, cfg: &Config) -> Result { scope, removed_paths: removed, embedding_rows_truncated, + orphans_purged: 0, + purged_paths: Vec::new(), }) } +/// Execute the `OrphansOnly` variant: reconcile stored docs against the +/// current walker scope without touching any filesystem directory. +fn execute_orphans_only(cfg: &Config) -> Result { + let orphans = enumerate_orphans(cfg) + .context("execute_orphans_only: enumerate orphans")?; + + if orphans.is_empty() { + return Ok(ResetReport { + scope: ResetScope::OrphansOnly, + removed_paths: Vec::new(), + embedding_rows_truncated: 0, + orphans_purged: 0, + purged_paths: Vec::new(), + }); + } + + let store = std::sync::Arc::new( + kebab_store_sqlite::SqliteStore::open(cfg) + .context("execute_orphans_only: open SqliteStore")?, + ); + + // Open vector store if configured. Mirror the same guard the ingest + // path uses: only construct when the provider is not "none" / dims > 0. + let vector_store: Option = + open_vector_store_if_configured(cfg, store.clone())?; + + let mut purged_paths: Vec = Vec::new(); + + for path in &orphans { + let chunk_ids = kebab_store_sqlite::purge_deleted_workspace_path(&store, path) + .with_context(|| format!("execute_orphans_only: purge {}", path.0))?; + + if let Some(ref vs) = vector_store { + if !chunk_ids.is_empty() { + use kebab_core::VectorStore as _; + if let Err(e) = vs.delete_by_chunk_ids(&chunk_ids) { + tracing::warn!( + target: "kebab-app", + path = %path.0, + count = chunk_ids.len(), + error = %e, + "reset --orphans-only: vector delete failed; SQLite side already cleaned" + ); + } + } + } + + tracing::info!( + target: "kebab-app", + path = %path.0, + "reset --orphans-only: purged orphan document" + ); + purged_paths.push(path.clone()); + } + + let orphans_purged = u32::try_from(purged_paths.len()).unwrap_or(u32::MAX); + + Ok(ResetReport { + scope: ResetScope::OrphansOnly, + removed_paths: Vec::new(), + embedding_rows_truncated: 0, + orphans_purged, + purged_paths, + }) +} + +/// Open the Lance vector store if the configured embedding provider is +/// active (non-"none", dimensions > 0). Returns `None` for lexical-only +/// configs. Mirrors the guard in `App::vector`. +fn open_vector_store_if_configured( + cfg: &Config, + store: std::sync::Arc, +) -> Result> { + if cfg.models.embedding.provider == "none" || cfg.models.embedding.dimensions == 0 { + return Ok(None); + } + match kebab_store_vector::LanceVectorStore::new(cfg, store) { + Ok(vs) => Ok(Some(vs)), + Err(e) => { + tracing::warn!( + target: "kebab-app", + error = %e, + "reset --orphans-only: could not open vector store; skipping vector delete" + ); + Ok(None) + } + } +} + /// Open the SQLite store at the configured path and run /// `truncate_embedding_records`. Returns the count of truncated rows /// (the helper itself reports `DELETE` rowcount). If the SQLite file @@ -200,4 +384,14 @@ mod tests { let bytes = estimate_size_bytes(&[dir.path().to_path_buf()]); assert_eq!(bytes, 5 + 6); } + + #[test] + fn enumerate_orphans_only_returns_empty_paths() { + let cfg = Config::defaults(); + let paths = enumerate_paths(ResetScope::OrphansOnly, &cfg); + assert!( + paths.is_empty(), + "OrphansOnly must return empty vec from enumerate_paths" + ); + } } diff --git a/crates/kebab-app/tests/reset_orphans.rs b/crates/kebab-app/tests/reset_orphans.rs new file mode 100644 index 0000000..6f51f1a --- /dev/null +++ b/crates/kebab-app/tests/reset_orphans.rs @@ -0,0 +1,141 @@ +//! Integration test for `kebab reset --orphans-only`. +//! +//! Verifies that stored docs outside the current walker scope are purged +//! from the store without removing any files from the filesystem. +//! +//! Test outline: +//! 1. Ingest 3 .rs files (a.rs, b.rs, c.rs) — all New. +//! 2. Narrow the config `include` to `["a.rs"]` only; b.rs and c.rs are +//! still on disk but outside the walker scope. +//! 3. Run `execute(ResetScope::OrphansOnly, &cfg)` — report must show +//! `orphans_purged == 2` and `purged_paths` contains b.rs + c.rs. +//! 4. `list docs` must show only a.rs. +//! 5. b.rs and c.rs must still exist on disk (no filesystem removal). +//! 6. Second reset → `orphans_purged == 0` (idempotent). + +mod common; + +use common::TestEnv; +use kebab_app::IngestOpts; +use kebab_app::reset::{ResetScope, execute}; +use kebab_core::{DocFilter, DocumentStore, SourceScope}; + +/// Open the SqliteStore and list all `workspace_path` values. +fn list_doc_paths(env: &TestEnv) -> Vec { + use kebab_store_sqlite::SqliteStore; + let store = SqliteStore::open(&env.config).unwrap(); + store.run_migrations().unwrap(); + store + .list_documents(&DocFilter::default()) + .unwrap() + .into_iter() + .map(|d| d.doc_path.0) + .collect() +} + +#[test] +fn reset_orphans_only_purges_out_of_scope_docs() { + let env = TestEnv::lexical_only(); + + // Write three .rs files into the workspace. + let a_path = env.workspace_root.join("a.rs"); + let b_path = env.workspace_root.join("b.rs"); + let c_path = env.workspace_root.join("c.rs"); + std::fs::write(&a_path, "// file a\nfn alpha() {}\n").unwrap(); + std::fs::write(&b_path, "// file b\nfn bravo() {}\n").unwrap(); + std::fs::write(&c_path, "// file c\nfn charlie() {}\n").unwrap(); + + // Ingest all three with a wide scope. + let wide_scope = SourceScope { + root: env.workspace_root.clone(), + include: vec!["**/*.rs".to_string()], + exclude: env.config.workspace.exclude.clone(), + }; + let first = kebab_app::ingest_with_config_opts( + env.config.clone(), + wide_scope, + false, + IngestOpts::default(), + ) + .expect("first ingest must succeed"); + // The fixture workspace may contain other .rs files — just assert we + // got at least 3 new docs (our a.rs, b.rs, c.rs). + assert!(first.new >= 3, "expected at least 3 new docs: {first:?}"); + assert_eq!(first.errors, 0, "no errors on first ingest"); + + // Narrow config to include only a.rs; b.rs + c.rs are still on disk. + let mut narrow_cfg = env.config.clone(); + narrow_cfg.workspace.exclude.clear(); + // Re-point workspace root (already correct) and restrict include via + // the SourceScope in the connector. The config's `workspace.root` is + // used by `enumerate_orphans` to build its scope — we keep that + // pointing at the workspace root. We simulate narrowing by setting a + // glob that only matches a.rs. + // + // NOTE: `kebab_config::WorkspaceCfg` does not have an `include` field + // (it was removed in p9-fb-25). We narrow the scope via the walker + // exclude list: exclude b.rs and c.rs explicitly. + narrow_cfg.workspace.exclude = vec!["b.rs".to_string(), "c.rs".to_string()]; + + // Run orphans-only reset. + let report = execute(ResetScope::OrphansOnly, &narrow_cfg) + .expect("orphans-only reset must succeed"); + + assert_eq!( + report.orphans_purged, 2, + "expected 2 orphans purged (b.rs + c.rs): {report:?}" + ); + + let mut purged: Vec = report + .purged_paths + .iter() + .map(|p| p.0.clone()) + .collect(); + purged.sort(); + assert_eq!( + purged, + vec!["b.rs".to_string(), "c.rs".to_string()], + "purged_paths must list b.rs and c.rs in sorted order: {purged:?}" + ); + + // list docs must show only a.rs (and any pre-existing fixture files + // that are not excluded by the narrow config). + let doc_paths = list_doc_paths(&env); + // The narrow_cfg excludes b.rs + c.rs — they must no longer be in store. + assert!( + !doc_paths.iter().any(|p| p == "b.rs"), + "b.rs must be gone from store after orphans-only reset; got: {doc_paths:?}" + ); + assert!( + !doc_paths.iter().any(|p| p == "c.rs"), + "c.rs must be gone from store after orphans-only reset; got: {doc_paths:?}" + ); + assert!( + doc_paths.iter().any(|p| p == "a.rs"), + "a.rs must still be in store; got: {doc_paths:?}" + ); + + // Both b.rs and c.rs must still exist on the filesystem — no file + // removal is performed by orphans-only. + assert!( + b_path.exists(), + "b.rs must still be on disk after orphans-only reset" + ); + assert!( + c_path.exists(), + "c.rs must still be on disk after orphans-only reset" + ); + + // Second reset must be idempotent: nothing left to purge. + let second = execute(ResetScope::OrphansOnly, &narrow_cfg) + .expect("second orphans-only reset must succeed"); + assert_eq!( + second.orphans_purged, 0, + "second reset must be idempotent (orphans_purged == 0): {second:?}" + ); + assert!( + second.purged_paths.is_empty(), + "second reset purged_paths must be empty: {:?}", + second.purged_paths + ); +} diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index 54a313e..fd32cc8 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -275,6 +275,14 @@ enum Cmd { #[arg(long, group = "reset_scope")] config_only: bool, + /// Purge stored docs that are outside the current walker scope + /// (config narrowing / removed sub-directory). No filesystem paths + /// are removed — this is purely a store-level reconciliation. + /// Filesystem existence is NOT checked; anything the current walker + /// would not visit is considered an orphan and removed from the store. + #[arg(long, group = "reset_scope")] + orphans_only: bool, + /// Skip the interactive confirm. Required in non-interactive /// contexts (CI, pipes). #[arg(long)] @@ -1094,6 +1102,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> { data_only: _, vector_only, config_only, + orphans_only, yes, } => { use kebab_app::ResetScope; @@ -1107,11 +1116,50 @@ fn run(cli: &Cli) -> anyhow::Result<()> { ResetScope::VectorOnly } else if *config_only { ResetScope::ConfigOnly + } else if *orphans_only { + ResetScope::OrphansOnly } else { ResetScope::DataOnly }; let cfg = kebab_config::Config::load(cli.config.as_deref())?; + + if matches!(scope, ResetScope::OrphansOnly) { + // OrphansOnly: confirm UI shows orphan count + sample paths + // rather than on-disk directory sizes. + let orphan_paths = kebab_app::enumerate_orphans(&cfg)?; + + if !*yes { + use std::io::IsTerminal; + if !std::io::stdin().is_terminal() { + anyhow::bail!( + "reset --orphans-only is destructive and stdin is non-interactive — pass --yes to proceed" + ); + } + if !confirm_orphans_only(&orphan_paths)? { + if !cli.quiet { + eprintln!("aborted."); + } + return Ok(()); + } + } + + let report = kebab_app::reset::execute(scope, &cfg)?; + if cli.json { + println!("{}", serde_json::to_string(&wire::wire_reset(&report))?); + } else { + if report.orphans_purged > 0 { + println!("orphans purged: {}", report.orphans_purged); + for p in &report.purged_paths { + println!(" - {}", p.0); + } + } else { + println!("no orphaned docs found — store is already in sync with walker scope"); + } + } + return Ok(()); + } + let paths = kebab_app::reset::enumerate_paths(scope, &cfg); let bytes = kebab_app::reset::estimate_size_bytes(&paths); @@ -1450,6 +1498,46 @@ fn confirm_destructive( Ok(matches!(s.as_str(), "y" | "yes")) } +/// Confirm prompt for `--orphans-only`: shows the orphan count + a +/// sample of up to 5 paths so the user knows what will be purged before +/// committing. No filesystem paths are removed — only store records. +fn confirm_orphans_only( + orphan_paths: &[kebab_core::WorkspacePath], +) -> anyhow::Result { + use std::io::Write; + let n = orphan_paths.len(); + let mut out = std::io::stderr().lock(); + + if n == 0 { + writeln!(out, "no orphaned docs found — nothing to purge.")?; + out.flush()?; + // Nothing to do; treat as confirmed so the caller can emit the + // "no orphans" report without prompting. + return Ok(true); + } + + let sample: Vec<&str> = orphan_paths + .iter() + .take(5) + .map(|p| p.0.as_str()) + .collect(); + let sample_str = sample.join(", "); + let ellipsis = if n > 5 { ", …" } else { "" }; + + writeln!( + out, + "Purge {n} stored doc(s) outside the current walker scope? (no filesystem paths removed)" + )?; + writeln!(out, " sample: {sample_str}{ellipsis}")?; + write!(out, "[y/N] ")?; + out.flush()?; + + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + let s = line.trim().to_ascii_lowercase(); + Ok(matches!(s.as_str(), "y" | "yes")) +} + /// p9-fb-35: human-friendly plain output for `kebab fetch`. fn render_fetch_plain(r: &kebab_core::FetchResult) { println!("# {} ({})", r.doc_path.0, format_kind(r.kind)); diff --git a/crates/kebab-cli/src/wire.rs b/crates/kebab-cli/src/wire.rs index 14a3623..01951c9 100644 --- a/crates/kebab-cli/src/wire.rs +++ b/crates/kebab-cli/src/wire.rs @@ -365,6 +365,8 @@ mod tests { scope: kebab_app::ResetScope::DataOnly, removed_paths: vec![std::path::PathBuf::from("/tmp/x")], embedding_rows_truncated: 0, + orphans_purged: 0, + purged_paths: vec![], }; let v = wire_reset(&r); assert_eq!(schema_of(&v), Some("reset_report.v1")); -- 2.49.1 From 749c6ae240274e055e79038799a91b4d492fc1a2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Wed, 20 May 2026 07:47:44 +0000 Subject: [PATCH 2/2] docs(dogfood): sync reset_report schema + README for --orphans-only (PR #149 review) Round 1 review found 2 doc gaps: - docs/wire-schema/v1/reset_report.schema.json: 'orphans_only' missing from scope enum; orphans_purged/purged_paths properties absent - README: --orphans-only not listed in the reset prose Schema additions are additive minor (default values keep back-compat). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- docs/wire-schema/v1/reset_report.schema.json | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7e9ece5..ac2cb57 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ cargo install --git https://gitea.altair823.xyz/altair823-org/kebab.git --bin ke 업데이트는 `git pull && cargo install --path crates/kebab-cli --locked --force` 또는 git URL 형식의 경우 `cargo install --git ... --force`. -제거는 `cargo uninstall kebab-cli`. 이 명령은 binary 만 지우고 워크스페이스 데이터는 그대로 남는다. 데이터까지 정리하려면 `kebab reset --all --yes` (config + data + cache + state 4 개 XDG 경로 모두 wipe — **irreversible**, 재시작 시 `kebab init` 다시 실행). 부분 wipe 는 `kebab reset --data-only` (config 보존), `kebab reset --vector-only` (Lance + `embedding_records` 만, 다음 ingest 가 re-embed) 등. +제거는 `cargo uninstall kebab-cli`. 이 명령은 binary 만 지우고 워크스페이스 데이터는 그대로 남는다. 데이터까지 정리하려면 `kebab reset --all --yes` (config + data + cache + state 4 개 XDG 경로 모두 wipe — **irreversible**, 재시작 시 `kebab init` 다시 실행). 부분 wipe 는 `kebab reset --data-only` (config 보존), `kebab reset --vector-only` (Lance + `embedding_records` 만, 다음 ingest 가 re-embed), **`kebab reset --orphans-only`** (현재 walker scope 밖에 있는 stored doc 만 정리 — `config.workspace.include` 좁히거나 sub-dir 옮긴 후 explicit reconcile; fs 의 file 은 건드리지 않음) 등. ## Quick start diff --git a/docs/wire-schema/v1/reset_report.schema.json b/docs/wire-schema/v1/reset_report.schema.json index ab34d8c..4fa6906 100644 --- a/docs/wire-schema/v1/reset_report.schema.json +++ b/docs/wire-schema/v1/reset_report.schema.json @@ -14,12 +14,18 @@ "schema_version": { "const": "reset_report.v1" }, "scope": { "type": "string", - "enum": ["all", "data_only", "vector_only", "config_only"] + "enum": ["all", "data_only", "vector_only", "config_only", "orphans_only"] }, "removed_paths": { "type": "array", "items": { "type": "string" } }, - "embedding_rows_truncated": { "type": "integer", "minimum": 0 } + "embedding_rows_truncated": { "type": "integer", "minimum": 0 }, + "orphans_purged": { "type": "integer", "minimum": 0, "default": 0 }, + "purged_paths": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } } } -- 2.49.1