feat(api): on-write search reindex after catalogue writes (#17)
Wire best-effort Meilisearch index sync into the admin write paths (create/update/delete/set_fields/set_visibility). Adds SearchClient::sync_object (reindex if the object exists, remove if gone — one uniform path), an optional AppState.search client, and a reindex helper that logs failures via tracing without failing the committed write. Server gains MEILI_URL/MEILI_MASTER_KEY/MEILI_INDEX config; search stays disabled (no-op) when unset. reindex_all remains the recovery path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ pub struct SearchDocument {
|
||||
}
|
||||
|
||||
/// A Meilisearch-backed search client scoped to one index.
|
||||
#[derive(Clone)]
|
||||
pub struct SearchClient {
|
||||
client: meilisearch_sdk::client::Client,
|
||||
index_uid: String,
|
||||
@@ -146,6 +147,23 @@ impl SearchClient {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sync a single object's index entry with the database after a catalogue write
|
||||
/// commits: re-project and index it if it still exists, otherwise remove it. This
|
||||
/// is the uniform on-write path for create/update/delete/field/visibility changes —
|
||||
/// a delete (object gone) removes the entry; everything else re-indexes the current
|
||||
/// projection. Best-effort: callers invoke it after the DB transaction commits and
|
||||
/// log (not propagate) any error, since `reindex_all` is the recovery path.
|
||||
pub async fn sync_object(&self, db: &Db, id: ObjectId) -> Result<(), SearchError> {
|
||||
match db::catalog::object_by_id(db.pool(), id).await? {
|
||||
Some(object) => {
|
||||
let document = build_document(db, &object).await?;
|
||||
|
||||
self.index_object(&document).await
|
||||
}
|
||||
None => self.remove_object(id).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
use db::{Db, catalog};
|
||||
use domain::{AuditActor, 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!("sync_test_{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn object(number: &str, name: &str) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: name.into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn sync_object_indexes_then_removes(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &object("S-1", "lamp"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let (url, key) = meili();
|
||||
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
|
||||
client.ensure_index().await.unwrap();
|
||||
|
||||
// object exists -> sync indexes it
|
||||
client.sync_object(&db, id).await.unwrap();
|
||||
assert_eq!(client.search("lamp").await.unwrap(), vec![id]);
|
||||
|
||||
// object deleted -> sync removes it from the index
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
let existed = catalog::delete_object(&mut tx, AuditActor::System, id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(existed);
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
client.sync_object(&db, id).await.unwrap();
|
||||
assert!(client.search("lamp").await.unwrap().is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user