From dc903989f72324db458644df23199d548cff08bd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 11:43:53 +0200 Subject: [PATCH 1/5] feat(search): add Meilisearch-backed SearchClient (index, search, remove) Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 3 +- crates/search/Cargo.toml | 20 +++++ crates/search/src/lib.rs | 149 ++++++++++++++++++++++++++++++++++ crates/search/tests/search.rs | 52 ++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 crates/search/Cargo.toml create mode 100644 crates/search/src/lib.rs create mode 100644 crates/search/tests/search.rs diff --git a/Cargo.toml b/Cargo.toml index 1522aa4..fdb73ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/domain", "crates/db", "crates/api", "crates/server"] +members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search"] [workspace.package] edition = "2024" @@ -23,3 +23,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +meilisearch-sdk = "0.33" diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml new file mode 100644 index 0000000..858a735 --- /dev/null +++ b/crates/search/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "search" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +meilisearch-sdk.workspace = true +serde = { workspace = true } +thiserror.workspace = true +domain = { path = "../domain" } +db = { path = "../db" } +sqlx.workspace = true + +[dev-dependencies] +tokio.workspace = true +uuid.workspace = true +serde_json.workspace = true +sqlx.workspace = true +domain = { path = "../domain" } diff --git a/crates/search/src/lib.rs b/crates/search/src/lib.rs new file mode 100644 index 0000000..6dc4471 --- /dev/null +++ b/crates/search/src/lib.rs @@ -0,0 +1,149 @@ +//! Full-text search over catalogue objects, backed by Meilisearch. + +use db::Db; +use domain::{CatalogueObject, ObjectId}; +use serde::{Deserialize, Serialize}; + +/// Errors from the search subsystem. +#[derive(Debug, thiserror::Error)] +pub enum SearchError { + #[error(transparent)] + Meili(#[from] meilisearch_sdk::errors::Error), + + #[error(transparent)] + Db(#[from] sqlx::Error), + + #[error("invalid object id in index: {0}")] + BadId(String), +} + +/// The indexed shape of a catalogue object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchDocument { + pub id: String, + pub object_number: String, + pub object_name: String, + pub brief_description: Option, + pub current_owner: Option, + pub recorder: Option, + /// Filterable: "draft" | "internal" | "public". + pub visibility: String, + /// Flexible field values flattened to searchable text. + pub fields_text: Vec, +} + +/// A Meilisearch-backed search client scoped to one index. +pub struct SearchClient { + client: meilisearch_sdk::client::Client, + index_uid: String, +} + +impl SearchClient { + pub fn connect(url: &str, api_key: &str, index_uid: &str) -> Result { + let client = meilisearch_sdk::client::Client::new(url, Some(api_key))?; + + Ok(Self { + client, + index_uid: index_uid.to_owned(), + }) + } + + pub async fn ensure_index(&self) -> Result<(), SearchError> { + self.client + .create_index(&self.index_uid, Some("id")) + .await? + .wait_for_completion(&self.client, None, None) + .await?; + + self.client + .index(&self.index_uid) + .set_filterable_attributes(["visibility"]) + .await? + .wait_for_completion(&self.client, None, None) + .await?; + + Ok(()) + } + + pub async fn index_object(&self, doc: &SearchDocument) -> Result<(), SearchError> { + self.client + .index(&self.index_uid) + .add_or_replace(std::slice::from_ref(doc), Some("id")) + .await? + .wait_for_completion(&self.client, None, None) + .await?; + + Ok(()) + } + + pub async fn remove_object(&self, id: ObjectId) -> Result<(), SearchError> { + self.client + .index(&self.index_uid) + .delete_document(id.to_string()) + .await? + .wait_for_completion(&self.client, None, None) + .await?; + + Ok(()) + } + + pub async fn search(&self, query: &str) -> Result, SearchError> { + let index = self.client.index(&self.index_uid); + + let results = index + .search() + .with_query(query) + .build() + .execute::() + .await?; + + results + .hits + .into_iter() + .map(|hit| { + hit.result + .id + .parse::() + .map_err(|_| SearchError::BadId(hit.result.id)) + }) + .collect() + } + + /// Rebuild the whole index from the database. (build_document is filled in Task 2.) + pub async fn reindex_all(&self, db: &Db) -> Result<(), SearchError> { + let index = self.client.index(&self.index_uid); + + index + .delete_all_documents() + .await? + .wait_for_completion(&self.client, None, None) + .await?; + + let objects = db::catalog::list_objects(db.pool()).await?; + + let mut docs = Vec::with_capacity(objects.len()); + + for object in &objects { + docs.push(build_document(db, object).await?); + } + + if !docs.is_empty() { + index + .add_or_replace(&docs, Some("id")) + .await? + .wait_for_completion(&self.client, None, None) + .await?; + } + + Ok(()) + } +} + +/// Build a SearchDocument from an object (implemented in Task 2). +#[allow(clippy::unimplemented)] // Task 2 +pub async fn build_document( + _db: &Db, + _object: &CatalogueObject, +) -> Result { + unimplemented!("implemented in Task 2") +} diff --git a/crates/search/tests/search.rs b/crates/search/tests/search.rs new file mode 100644 index 0000000..f120037 --- /dev/null +++ b/crates/search/tests/search.rs @@ -0,0 +1,52 @@ +use search::{SearchClient, SearchDocument}; + +fn meili() -> (String, String) { + ( + std::env::var("MEILI_URL").expect("MEILI_URL must be set"), + std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"), + ) +} + +fn unique_index() -> String { + format!("objects_test_{}", uuid::Uuid::new_v4().simple()) +} + +fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument { + SearchDocument { + id: id.to_string(), + object_number: format!("N-{id}"), + object_name: object_name.to_string(), + brief_description: None, + current_owner: None, + recorder: None, + visibility: "draft".to_string(), + fields_text: fields_text.iter().map(|s| s.to_string()).collect(), + } +} + +#[tokio::test] +async fn index_search_and_remove() { + let (url, key) = meili(); + let client = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + client.ensure_index().await.unwrap(); + + let vase = domain::ObjectId::new(); + let chair = domain::ObjectId::new(); + client + .index_object(&doc(&vase.to_string(), "vase", &["wood", "trä"])) + .await + .unwrap(); + client + .index_object(&doc(&chair.to_string(), "chair", &["oak"])) + .await + .unwrap(); + + let hits = client.search("wood").await.unwrap(); + assert_eq!(hits, vec![vase]); + + let hits = client.search("chair").await.unwrap(); + assert_eq!(hits, vec![chair]); + + client.remove_object(vase).await.unwrap(); + assert!(client.search("wood").await.unwrap().is_empty()); +} From b8d198f150ba63cfc507689e657bd35f892091e4 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 11:50:58 +0200 Subject: [PATCH 2/5] fix(search): surface failed Meilisearch tasks; make ensure_index idempotent Co-Authored-By: Claude Sonnet 4.6 --- crates/search/src/lib.rs | 47 ++++++++++++++++++++++++++++++----- crates/search/tests/search.rs | 18 ++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/search/src/lib.rs b/crates/search/src/lib.rs index 6dc4471..c58f0ad 100644 --- a/crates/search/src/lib.rs +++ b/crates/search/src/lib.rs @@ -2,6 +2,7 @@ use db::Db; use domain::{CatalogueObject, ObjectId}; +use meilisearch_sdk::tasks::Task; use serde::{Deserialize, Serialize}; /// Errors from the search subsystem. @@ -38,6 +39,16 @@ pub struct SearchClient { index_uid: String, } +/// Turn a completed task into an error if Meilisearch rejected it. +fn check_task(task: Task) -> Result<(), SearchError> { + match task { + Task::Failed { content } => Err(SearchError::Meili( + meilisearch_sdk::errors::Error::Meilisearch(content.error), + )), + _ => Ok(()), + } +} + impl SearchClient { pub fn connect(url: &str, api_key: &str, index_uid: &str) -> Result { let client = meilisearch_sdk::client::Client::new(url, Some(api_key))?; @@ -49,41 +60,61 @@ impl SearchClient { } pub async fn ensure_index(&self) -> Result<(), SearchError> { - self.client + let task = self + .client .create_index(&self.index_uid, Some("id")) .await? .wait_for_completion(&self.client, None, None) .await?; - self.client + // Tolerate "index already exists"; surface any other task failure. + if let Task::Failed { content } = &task { + if content.error.error_code != meilisearch_sdk::errors::ErrorCode::IndexAlreadyExists { + return Err(SearchError::Meili( + meilisearch_sdk::errors::Error::Meilisearch(content.error.clone()), + )); + } + } + + // set_filterable_attributes is idempotent on an existing index + let task = self + .client .index(&self.index_uid) .set_filterable_attributes(["visibility"]) .await? .wait_for_completion(&self.client, None, None) .await?; + check_task(task)?; + Ok(()) } pub async fn index_object(&self, doc: &SearchDocument) -> Result<(), SearchError> { - self.client + let task = self + .client .index(&self.index_uid) .add_or_replace(std::slice::from_ref(doc), Some("id")) .await? .wait_for_completion(&self.client, None, None) .await?; + check_task(task)?; + Ok(()) } pub async fn remove_object(&self, id: ObjectId) -> Result<(), SearchError> { - self.client + let task = self + .client .index(&self.index_uid) .delete_document(id.to_string()) .await? .wait_for_completion(&self.client, None, None) .await?; + check_task(task)?; + Ok(()) } @@ -113,12 +144,14 @@ impl SearchClient { pub async fn reindex_all(&self, db: &Db) -> Result<(), SearchError> { let index = self.client.index(&self.index_uid); - index + let task = index .delete_all_documents() .await? .wait_for_completion(&self.client, None, None) .await?; + check_task(task)?; + let objects = db::catalog::list_objects(db.pool()).await?; let mut docs = Vec::with_capacity(objects.len()); @@ -128,11 +161,13 @@ impl SearchClient { } if !docs.is_empty() { - index + let task = index .add_or_replace(&docs, Some("id")) .await? .wait_for_completion(&self.client, None, None) .await?; + + check_task(task)?; } Ok(()) diff --git a/crates/search/tests/search.rs b/crates/search/tests/search.rs index f120037..dcac090 100644 --- a/crates/search/tests/search.rs +++ b/crates/search/tests/search.rs @@ -50,3 +50,21 @@ async fn index_search_and_remove() { client.remove_object(vase).await.unwrap(); assert!(client.search("wood").await.unwrap().is_empty()); } + +#[tokio::test] +async fn ensure_index_is_idempotent() { + let (url, key) = meili(); + let index = unique_index(); + let client = SearchClient::connect(&url, &key, &index).unwrap(); + client.ensure_index().await.unwrap(); + // second call against the now-existing index must succeed + client.ensure_index().await.unwrap(); + + // and the client still works + let id = domain::ObjectId::new(); + client + .index_object(&doc(&id.to_string(), "lamp", &[])) + .await + .unwrap(); + assert_eq!(client.search("lamp").await.unwrap(), vec![id]); +} From 7b91989411826e07eacfdc6437c2bf5edfd350d5 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 12:08:07 +0200 Subject: [PATCH 3/5] feat(search): build documents resolving term/authority labels; reindex_all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements build_document in the search crate: resolves Term and Authority flexible-field values to their human-readable labels so reindex_all produces documents that Meilisearch can match on label text, not raw UUIDs. Adds integration test covering the full reindex→search round-trip. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 321 ++++++++++++++++++++++++++++++++- crates/search/src/lib.rs | 76 +++++++- crates/search/tests/reindex.rs | 108 +++++++++++ 3 files changed, 490 insertions(+), 15 deletions(-) create mode 100644 crates/search/tests/reindex.rs diff --git a/Cargo.lock b/Cargo.lock index cdf4647..8c4b4df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,17 @@ dependencies = [ "utoipa", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "2.0.0" @@ -109,6 +120,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.9" @@ -216,6 +250,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -271,6 +307,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -292,6 +337,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -348,7 +402,7 @@ dependencies = [ "domain", "serde_json", "sqlx", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "uuid", @@ -414,6 +468,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.16.0" @@ -478,6 +538,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -493,6 +559,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -537,6 +609,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -557,6 +640,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -614,6 +698,25 @@ dependencies = [ "wasip3", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -734,6 +837,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -916,12 +1020,31 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -934,6 +1057,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1035,6 +1173,49 @@ dependencies = [ "digest", ] +[[package]] +name = "meilisearch-index-setting-macro" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93b5b21df781c820a9cc387b808d4128cbc164dd28d67ac6ed666a00996f8f15" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "structmeta", + "syn", +] + +[[package]] +name = "meilisearch-sdk" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e6e3646ba2a9a306296c1edf4a050508a408c1b59ca456d9ad4965ec6e91e9" +dependencies = [ + "async-trait", + "bytes", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "iso8601", + "jsonwebtoken", + "log", + "meilisearch-index-setting-macro", + "pin-project-lite", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "uuid", + "wasm-bindgen-futures", + "web-sys", + "yaup", +] + [[package]] name = "memchr" version = "2.8.1" @@ -1058,6 +1239,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1271,7 +1461,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1292,7 +1482,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1436,6 +1626,8 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -1455,12 +1647,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.7", ] @@ -1475,7 +1669,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1537,7 +1731,7 @@ checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1558,6 +1752,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "search" +version = "0.0.0" +dependencies = [ + "db", + "domain", + "meilisearch-sdk", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "semver" version = "1.0.28" @@ -1790,7 +1999,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -1875,7 +2084,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -1914,7 +2123,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -1940,7 +2149,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -1970,6 +2179,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2016,13 +2248,33 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2150,6 +2402,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -2297,12 +2562,24 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2503,6 +2780,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -2900,6 +3190,17 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yaup" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0144f1a16a199846cb21024da74edd930b43443463292f536b7110b4855b5c6" +dependencies = [ + "form_urlencoded", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/search/src/lib.rs b/crates/search/src/lib.rs index c58f0ad..94e370f 100644 --- a/crates/search/src/lib.rs +++ b/crates/search/src/lib.rs @@ -174,11 +174,77 @@ impl SearchClient { } } -/// Build a SearchDocument from an object (implemented in Task 2). -#[allow(clippy::unimplemented)] // Task 2 +/// Build a [`SearchDocument`] from a catalogue object, resolving term and authority +/// references to their human-readable labels so Meilisearch can match on them. pub async fn build_document( - _db: &Db, - _object: &CatalogueObject, + db: &Db, + object: &CatalogueObject, ) -> Result { - unimplemented!("implemented in Task 2") + let mut fields_text = Vec::new(); + + if let Some(map) = object.fields.as_object() { + for (key, value) in map { + let Some(def) = db::fields::field_definition_by_key(db.pool(), key).await? else { + // Stale field with no definition — skip. + continue; + }; + + match def.field_type { + domain::FieldType::Text | domain::FieldType::Date => { + if let Some(s) = value.as_str() { + fields_text.push(s.to_owned()); + } + } + + domain::FieldType::Integer | domain::FieldType::Boolean => { + fields_text.push(value.to_string()); + } + + domain::FieldType::LocalizedText => { + if let Some(obj) = value.as_object() { + for v in obj.values() { + if let Some(s) = v.as_str() { + fields_text.push(s.to_owned()); + } + } + } + } + + domain::FieldType::Term { .. } => { + if let Some(term_id) = value + .as_str() + .and_then(|s| s.parse::().ok()) + { + if let Some(term) = db::vocab::term_by_id(db.pool(), term_id).await? { + fields_text.extend(term.labels.into_iter().map(|l| l.label)); + } + } + } + + domain::FieldType::Authority { .. } => { + if let Some(authority_id) = value + .as_str() + .and_then(|s| s.parse::().ok()) + { + if let Some(authority) = + db::authority::authority_by_id(db.pool(), authority_id).await? + { + fields_text.extend(authority.labels.into_iter().map(|l| l.label)); + } + } + } + } + } + } + + Ok(SearchDocument { + id: object.id.to_string(), + object_number: object.object_number.clone(), + object_name: object.object_name.clone(), + brief_description: object.brief_description.clone(), + current_owner: object.current_owner.clone(), + recorder: object.recorder.clone(), + visibility: object.visibility.as_str().to_owned(), + fields_text, + }) } diff --git a/crates/search/tests/reindex.rs b/crates/search/tests/reindex.rs new file mode 100644 index 0000000..ec06a89 --- /dev/null +++ b/crates/search/tests/reindex.rs @@ -0,0 +1,108 @@ +use db::{Db, catalog, fields, vocab}; +use domain::{ + AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput, Visibility, +}; +use search::SearchClient; +use sqlx::PgPool; + +fn meili() -> (String, String) { + ( + std::env::var("MEILI_URL").expect("MEILI_URL must be set"), + std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"), + ) +} + +fn unique_index() -> String { + format!("reindex_test_{}", uuid::Uuid::new_v4().simple()) +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) { + let db = Db::from_pool(pool); + + // a material vocabulary with a "wood" term + let material = vocab::create_vocabulary(db.pool(), "material") + .await + .unwrap(); + + let mut tx = db.pool().begin().await.unwrap(); + + let wood = vocab::add_term( + &mut tx, + &NewTerm { + vocabulary_id: material.id, + external_uri: None, + labels: vec![LocalizedLabel { + lang: "en".into(), + label: "wood".into(), + }], + }, + ) + .await + .unwrap(); + + fields::create_field_definition( + &mut tx, + &NewFieldDefinition { + key: "material".into(), + field_type: FieldType::Term { + vocabulary_id: material.id, + }, + required: false, + group_key: None, + labels: vec![LocalizedLabel { + lang: "en".into(), + label: "material".into(), + }], + }, + ) + .await + .unwrap(); + + let object_id = catalog::create_object( + &mut tx, + AuditActor::System, + &ObjectInput { + object_number: "LM-1".into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility: Visibility::Public, + }, + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); + + // set the material field to the wood term + let mut tx = db.pool().begin().await.unwrap(); + + catalog::set_object_fields( + &mut tx, + AuditActor::System, + object_id, + serde_json::json!({ "material": wood.to_string() }) + .as_object() + .unwrap(), + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); + + let (url, key) = meili(); + let client = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + + client.ensure_index().await.unwrap(); + client.reindex_all(&db).await.unwrap(); + + // found by the object name + assert_eq!(client.search("vase").await.unwrap(), vec![object_id]); + // found by the resolved TERM LABEL (not the uuid) + assert_eq!(client.search("wood").await.unwrap(), vec![object_id]); +} From 4bafac397a30d830e60f26d5c172f4efdb03b23d Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 12:12:12 +0200 Subject: [PATCH 4/5] docs(search): note why reindex test references db crate migrations --- crates/search/tests/reindex.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/search/tests/reindex.rs b/crates/search/tests/reindex.rs index ec06a89..6f2be2f 100644 --- a/crates/search/tests/reindex.rs +++ b/crates/search/tests/reindex.rs @@ -16,6 +16,8 @@ fn unique_index() -> String { format!("reindex_test_{}", uuid::Uuid::new_v4().simple()) } +// Path is relative to this crate's root; the schema lives in the `db` crate. +// If the workspace layout changes, update this path. #[sqlx::test(migrations = "../db/migrations")] async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) { let db = Db::from_pool(pool); From fac4b703ff2546025e1bbec4faa6698b0d926fac Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 12:15:18 +0200 Subject: [PATCH 5/5] docs(search): document eventual-consistency model; drop stale Task 2 note --- crates/search/src/lib.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/search/src/lib.rs b/crates/search/src/lib.rs index 94e370f..c1f521c 100644 --- a/crates/search/src/lib.rs +++ b/crates/search/src/lib.rs @@ -1,4 +1,10 @@ //! Full-text search over catalogue objects, backed by Meilisearch. +//! +//! This crate provides the search *capability* plus a `reindex_all` rebuild path. +//! On-write index sync (calling `index_object`/`remove_object` after a catalogue +//! mutation commits) is wired at the API/service layer (Plan 7+). Meilisearch is not +//! transactional with Postgres, so the index is eventually consistent; `reindex_all` +//! is the recovery path. use db::Db; use domain::{CatalogueObject, ObjectId}; @@ -140,7 +146,7 @@ impl SearchClient { .collect() } - /// Rebuild the whole index from the database. (build_document is filled in Task 2.) + /// Rebuild the whole index from the database (clears then re-adds all objects). pub async fn reindex_all(&self, db: &Db) -> Result<(), SearchError> { let index = self.client.index(&self.index_uid);