From 65eb8e57bbdbe449f7463f0c9e8d1c1f3809e1ea Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Thu, 2 Jul 2026 23:47:41 -0600 Subject: [PATCH] fix(read): surface bare index id, not file stem, on the read path (U1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1 routed id derivation through id_for_path on the write/index side (insert, boot scan, watcher reindex, delete guard), so the documents index holds the bare id for a decorated path like {id}-{title}.md. But the READ path still took the id from the file stem: Collection::get/list and the typed get_document/list_documents returned read_document's stem-derived id, so get_dynamic/list_dynamic (and validate_all, doc_to_json) reported "{id}-{title}" as the id. Downstream ref JOINs key on the bare id, so a decorated stem produced an empty view — forcing consumers back to bare {id}.md filenames. Fix: all four read sites already fetch the authoritative index record; use record.id for the returned Document instead of the file stem. Views need no change — they read the documents index table, whose id column was already correct. Found by Donovan's live worker probe against main (2d08aee). Adds a regression test asserting get_dynamic, list_dynamic, and the typed read all return the bare id for a decorated {id}-{title}.md schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- grounddb/src/store/mod.rs | 59 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/grounddb/src/store/mod.rs b/grounddb/src/store/mod.rs index ea7b64f..4921c38 100644 --- a/grounddb/src/store/mod.rs +++ b/grounddb/src/store/mod.rs @@ -641,7 +641,10 @@ impl Store { let data: T = serde_yaml::from_value(raw_doc.data)?; Ok(Document { - id: raw_doc.id, + // Use the index record's id, not the file stem: for a decorated + // path (`{id}-{title}.md`) the stem is not the id. The index id is + // authoritative (derived via id_for_path on write/reindex). + id: record.id, created_at: raw_doc.created_at, modified_at: raw_doc.modified_at, data, @@ -663,7 +666,8 @@ impl Store { if let Ok(raw_doc) = document::read_document(&file_path) { if let Ok(data) = serde_yaml::from_value(raw_doc.data) { docs.push(Document { - id: raw_doc.id, + // Index id is authoritative (see get_document). + id: record.id.clone(), created_at: raw_doc.created_at, modified_at: raw_doc.modified_at, data, @@ -1761,7 +1765,11 @@ impl<'a> Collection<'a> { })?; let file_path = self.store.root.join(&record.path); - document::read_document(&file_path) + let mut doc = document::read_document(&file_path)?; + // The index id is authoritative; the file stem is not the id for a + // decorated path (`{id}-{title}.md`). + doc.id = record.id; + Ok(doc) } /// List all documents in this collection @@ -1773,7 +1781,11 @@ impl<'a> Collection<'a> { let file_path = self.store.root.join(&record.path); if file_path.exists() { match document::read_document(&file_path) { - Ok(doc) => docs.push(doc), + Ok(mut doc) => { + // Index id is authoritative (see Collection::get). + doc.id = record.id.clone(); + docs.push(doc); + } Err(e) => { log::warn!("Failed to read document {}: {}", record.path, e); } @@ -3595,6 +3607,45 @@ collections: ); } + /// The read APIs (get_dynamic / list_dynamic, and their typed twins) must + /// surface the stored bare id, not the decorated file stem. The index row + /// already holds the bare id (Issue 1); previously the read path re-derived + /// id from the file stem, so list_dynamic reported `{id}-{title}` and broke + /// ref JOINs whose key is the bare id. (U1b — found by Donovan's live probe.) + #[test] + fn test_decorated_stem_read_apis_return_bare_id() { + let (_tmp, store) = setup_decorated_store(); + let id = store + .collection("tasks") + .unwrap() + .insert( + serde_yaml::from_str("title: Ship It\nstatus: open").unwrap(), + None, + ) + .unwrap(); + assert!(!id.contains("ship-it"), "insert returns the bare ulid, got {id}"); + + // get_dynamic surfaces the bare id, not the decorated stem. + let got = store.get_dynamic("tasks", &id).unwrap(); + assert_eq!(got["id"], serde_json::Value::String(id.clone())); + + // list_dynamic surfaces the bare id — the field ref JOINs key on. + let list = store.list_dynamic("tasks", &HashMap::new()).unwrap(); + let arr = list.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!( + arr[0]["id"], + serde_json::Value::String(id.clone()), + "list_dynamic must report the bare id, not the file stem" + ); + + // Typed read path agrees too. + let typed = store + .get_document::("tasks", &id) + .unwrap(); + assert_eq!(typed.id, id); + } + // ── Issue 3: watcher-path validation + non-aborting drain ──────── /// A hand-edited file that violates the schema (strict collection) must be