1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex};
17
18use async_trait::async_trait;
19use rusqlite::Connection;
20use serde_json::Value;
21use tokio::sync::RwLock;
22
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum MetadataScope {
30 Mob(String),
31 Run(String, String),
32}
33
34impl MetadataScope {
35 pub fn mob_id(&self) -> &str {
37 match self {
38 Self::Mob(mob) => mob,
39 Self::Run(mob, _) => mob,
40 }
41 }
42
43 pub fn run_id(&self) -> Option<&str> {
45 match self {
46 Self::Mob(_) => None,
47 Self::Run(_, run) => Some(run),
48 }
49 }
50}
51
52#[derive(Debug, Clone, Default)]
57pub struct RuntimeMetadataTable {
58 inner: Arc<RwLock<BTreeMap<MetadataScope, BTreeMap<String, String>>>>,
59}
60
61impl RuntimeMetadataTable {
62 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub async fn set_labels(&self, scope: MetadataScope, labels: BTreeMap<String, String>) {
70 let mut guard = self.inner.write().await;
71 if labels.is_empty() {
72 guard.remove(&scope);
73 } else {
74 guard.insert(scope, labels);
75 }
76 }
77
78 pub async fn get_labels(&self, scope: &MetadataScope) -> BTreeMap<String, String> {
80 let guard = self.inner.read().await;
81 guard.get(scope).cloned().unwrap_or_default()
82 }
83
84 pub async fn delete_labels(&self, scope: &MetadataScope) -> Option<BTreeMap<String, String>> {
86 let mut guard = self.inner.write().await;
87 guard.remove(scope)
88 }
89
90 pub async fn list_labels_for_mob(
93 &self,
94 mob_id: &str,
95 ) -> Vec<(MetadataScope, BTreeMap<String, String>)> {
96 let guard = self.inner.read().await;
97 guard
98 .iter()
99 .filter(|(scope, _)| scope.mob_id() == mob_id)
100 .map(|(scope, labels)| (scope.clone(), labels.clone()))
101 .collect()
102 }
103}
104
105pub fn parse_labels_param(value: Option<&Value>) -> Result<BTreeMap<String, String>, String> {
111 match value {
112 None | Some(Value::Null) => Ok(BTreeMap::new()),
113 Some(v) => serde_json::from_value::<BTreeMap<String, String>>(v.clone())
114 .map_err(|err| format!("labels must be a map of string to string: {err}")),
115 }
116}
117
118pub fn labels_to_json_value(labels: &BTreeMap<String, String>) -> Value {
120 let mut map = serde_json::Map::with_capacity(labels.len());
121 for (k, v) in labels {
122 map.insert(k.clone(), Value::String(v.clone()));
123 }
124 Value::Object(map)
125}
126
127pub enum LabelRpcResult {
132 Accepted,
134 Labels(BTreeMap<String, String>),
136 InvalidParams(String),
138}
139
140pub async fn dispatch_labels_set(
142 table: &RuntimeMetadataTable,
143 scope: MetadataScope,
144 params: &Value,
145) -> LabelRpcResult {
146 match parse_labels_param(params.get("labels")) {
147 Ok(labels) => {
148 table.set_labels(scope, labels).await;
149 LabelRpcResult::Accepted
150 }
151 Err(message) => LabelRpcResult::InvalidParams(message),
152 }
153}
154
155pub async fn dispatch_labels_get(
157 table: &RuntimeMetadataTable,
158 scope: MetadataScope,
159) -> LabelRpcResult {
160 LabelRpcResult::Labels(table.get_labels(&scope).await)
161}
162
163pub async fn dispatch_labels_delete(
165 table: &RuntimeMetadataTable,
166 scope: MetadataScope,
167) -> LabelRpcResult {
168 let _ = table.delete_labels(&scope).await;
169 LabelRpcResult::Accepted
170}
171
172pub fn parse_run_id_param(params: &Value) -> Result<&str, String> {
174 match params.get("run_id").and_then(Value::as_str) {
175 Some(s) if !s.is_empty() => Ok(s),
176 _ => Err("run_id required".to_string()),
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum MetadataStoreError {
193 Io(String),
195 Decode(String),
198}
199
200impl std::fmt::Display for MetadataStoreError {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 match self {
203 Self::Io(msg) => write!(f, "metadata store io: {msg}"),
204 Self::Decode(msg) => write!(f, "metadata store decode: {msg}"),
205 }
206 }
207}
208
209impl std::error::Error for MetadataStoreError {}
210
211#[async_trait]
220pub trait PersistentMetadataStore: Send + Sync {
221 async fn get_subscription_cursor(
225 &self,
226 mob_id: &str,
227 ) -> Result<Option<u64>, MetadataStoreError>;
228
229 async fn set_subscription_cursor(
231 &self,
232 mob_id: &str,
233 cursor: u64,
234 ) -> Result<(), MetadataStoreError>;
235}
236
237#[derive(Debug, Default)]
246pub struct InMemoryMetadataStore {
247 cursors: RwLock<BTreeMap<String, u64>>,
248}
249
250impl InMemoryMetadataStore {
251 pub fn new() -> Self {
252 Self::default()
253 }
254}
255
256#[async_trait]
257impl PersistentMetadataStore for InMemoryMetadataStore {
258 async fn get_subscription_cursor(
259 &self,
260 mob_id: &str,
261 ) -> Result<Option<u64>, MetadataStoreError> {
262 Ok(self.cursors.read().await.get(mob_id).copied())
263 }
264
265 async fn set_subscription_cursor(
266 &self,
267 mob_id: &str,
268 cursor: u64,
269 ) -> Result<(), MetadataStoreError> {
270 self.cursors
271 .write()
272 .await
273 .insert(mob_id.to_string(), cursor);
274 Ok(())
275 }
276}
277
278pub struct SqliteMetadataStore {
304 conn: Mutex<Connection>,
305 db_path: PathBuf,
308}
309
310const SUBSCRIPTION_CURSOR_KEY: &str = "subscription_cursor";
311
312const MOBKIT_METADATA_DOMAIN: meerkat_sqlite::SchemaDomain = meerkat_sqlite::SchemaDomain {
315 name: "mobkit-metadata",
316 migrations: &[meerkat_sqlite::Migration {
317 version: 1,
318 name: "base-schema",
319 apply: migration_0001_metadata_schema,
320 }],
321 initialize_current: migration_0001_metadata_schema,
322 allowed_existing_versions: &[1],
323 released_predecessors: &[],
324 owned_objects: &[meerkat_sqlite::SchemaObject {
325 kind: meerkat_sqlite::SchemaObjectKind::Table,
326 name: "mobkit_metadata",
327 }],
328 retired_objects: &[],
329};
330
331fn migration_0001_metadata_schema(tx: &rusqlite::Transaction<'_>) -> Result<(), rusqlite::Error> {
332 tx.execute_batch(
333 "CREATE TABLE IF NOT EXISTS mobkit_metadata (
334 mob_id TEXT NOT NULL,
335 key TEXT NOT NULL,
336 value TEXT NOT NULL,
337 PRIMARY KEY (mob_id, key)
338 );",
339 )
340}
341
342impl SqliteMetadataStore {
343 pub fn open(path: impl AsRef<Path>) -> Result<Self, MetadataStoreError> {
349 let path = path.as_ref().to_path_buf();
350 let mut conn = meerkat_sqlite::open(&path, meerkat_sqlite::ConnectionProfile::PRIMARY)
351 .map_err(|err| MetadataStoreError::Io(format!("open: {err}")))?;
352 meerkat_sqlite::apply_domain_migrations(&mut conn, &MOBKIT_METADATA_DOMAIN)
353 .map_err(|err| MetadataStoreError::Io(format!("schema: {err}")))?;
354 Ok(Self {
355 conn: Mutex::new(conn),
356 db_path: path,
357 })
358 }
359
360 pub fn in_memory() -> Result<Self, MetadataStoreError> {
362 let mut conn = Connection::open_in_memory()
363 .map_err(|err| MetadataStoreError::Io(format!("in-memory open: {err}")))?;
364 meerkat_sqlite::apply_domain_migrations(&mut conn, &MOBKIT_METADATA_DOMAIN)
365 .map_err(|err| MetadataStoreError::Io(format!("schema: {err}")))?;
366 Ok(Self {
367 conn: Mutex::new(conn),
368 db_path: PathBuf::from(":memory:"),
369 })
370 }
371
372 fn operation_fence(&self) -> Result<meerkat_sqlite::OperationGuard, MetadataStoreError> {
376 meerkat_sqlite::OperationGuard::for_database(&self.db_path)
377 .map_err(|err| MetadataStoreError::Io(format!("operation fence: {err}")))
378 }
379
380 fn lock_conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, MetadataStoreError> {
381 self.conn
382 .lock()
383 .map_err(|err| MetadataStoreError::Io(format!("connection mutex poisoned: {err}")))
384 }
385}
386
387#[async_trait]
388impl PersistentMetadataStore for SqliteMetadataStore {
389 async fn get_subscription_cursor(
390 &self,
391 mob_id: &str,
392 ) -> Result<Option<u64>, MetadataStoreError> {
393 let _fence = self.operation_fence()?;
394 let conn = self.lock_conn()?;
395 let mut stmt = conn
396 .prepare_cached(
397 "SELECT value FROM mobkit_metadata WHERE mob_id = ?1 AND key = ?2 LIMIT 1",
398 )
399 .map_err(|err| MetadataStoreError::Io(format!("prepare: {err}")))?;
400 let value: Option<String> = stmt
401 .query_row(rusqlite::params![mob_id, SUBSCRIPTION_CURSOR_KEY], |row| {
402 row.get::<_, String>(0)
403 })
404 .map(Some)
405 .or_else(|err| match err {
406 rusqlite::Error::QueryReturnedNoRows => Ok(None),
407 other => Err(MetadataStoreError::Io(format!("query: {other}"))),
408 })?;
409 match value {
410 Some(s) => s
411 .parse::<u64>()
412 .map(Some)
413 .map_err(|err| MetadataStoreError::Decode(format!("cursor parse: {err}"))),
414 None => Ok(None),
415 }
416 }
417
418 async fn set_subscription_cursor(
419 &self,
420 mob_id: &str,
421 cursor: u64,
422 ) -> Result<(), MetadataStoreError> {
423 let _fence = self.operation_fence()?;
424 let conn = self.lock_conn()?;
425 conn.execute(
426 "INSERT INTO mobkit_metadata (mob_id, key, value) VALUES (?1, ?2, ?3) \
427 ON CONFLICT(mob_id, key) DO UPDATE SET value = excluded.value",
428 rusqlite::params![mob_id, SUBSCRIPTION_CURSOR_KEY, cursor.to_string()],
429 )
430 .map_err(|err| MetadataStoreError::Io(format!("upsert: {err}")))?;
431 Ok(())
432 }
433}
434
435#[cfg(test)]
436#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
437mod tests {
438 use super::*;
439
440 fn labels(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
441 pairs
442 .iter()
443 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
444 .collect()
445 }
446
447 #[tokio::test]
448 async fn set_and_get_mob_labels() {
449 let table = RuntimeMetadataTable::new();
450 let scope = MetadataScope::Mob("mob-a".to_string());
451 table
452 .set_labels(scope.clone(), labels(&[("repo", "agents"), ("env", "dev")]))
453 .await;
454 let got = table.get_labels(&scope).await;
455 assert_eq!(got.get("repo").map(String::as_str), Some("agents"));
456 assert_eq!(got.get("env").map(String::as_str), Some("dev"));
457 }
458
459 #[tokio::test]
460 async fn set_replaces_rather_than_merges() {
461 let table = RuntimeMetadataTable::new();
462 let scope = MetadataScope::Mob("mob-a".to_string());
463 table
464 .set_labels(scope.clone(), labels(&[("a", "1"), ("b", "2")]))
465 .await;
466 table.set_labels(scope.clone(), labels(&[("a", "9")])).await;
467 let got = table.get_labels(&scope).await;
468 assert_eq!(got.len(), 1);
469 assert_eq!(got.get("a").map(String::as_str), Some("9"));
470 assert!(!got.contains_key("b"));
471 }
472
473 #[tokio::test]
474 async fn delete_clears_entry() {
475 let table = RuntimeMetadataTable::new();
476 let scope = MetadataScope::Run("mob-a".to_string(), "run-1".to_string());
477 table.set_labels(scope.clone(), labels(&[("k", "v")])).await;
478 let prev = table.delete_labels(&scope).await;
479 assert_eq!(prev.unwrap().get("k").map(String::as_str), Some("v"));
480 let after = table.get_labels(&scope).await;
481 assert!(after.is_empty());
482 }
483
484 #[tokio::test]
485 async fn empty_set_clears_entry() {
486 let table = RuntimeMetadataTable::new();
487 let scope = MetadataScope::Mob("mob-a".to_string());
488 table.set_labels(scope.clone(), labels(&[("k", "v")])).await;
489 table.set_labels(scope.clone(), BTreeMap::new()).await;
490 assert!(table.get_labels(&scope).await.is_empty());
491 }
492
493 #[tokio::test]
494 async fn list_returns_mob_and_run_entries() {
495 let table = RuntimeMetadataTable::new();
496 let mob_scope = MetadataScope::Mob("mob-a".to_string());
497 let run_scope = MetadataScope::Run("mob-a".to_string(), "run-1".to_string());
498 let other_run = MetadataScope::Run("mob-b".to_string(), "run-1".to_string());
499 table
500 .set_labels(mob_scope.clone(), labels(&[("env", "dev")]))
501 .await;
502 table
503 .set_labels(run_scope.clone(), labels(&[("trace", "abc")]))
504 .await;
505 table
506 .set_labels(other_run, labels(&[("trace", "xyz")]))
507 .await;
508
509 let entries = table.list_labels_for_mob("mob-a").await;
510 assert_eq!(entries.len(), 2);
511 let scopes: Vec<&MetadataScope> = entries.iter().map(|(s, _)| s).collect();
512 assert!(scopes.contains(&&mob_scope));
513 assert!(scopes.contains(&&run_scope));
514 }
515
516 #[tokio::test]
519 async fn in_memory_persistent_store_round_trip() {
520 let store = InMemoryMetadataStore::new();
521 assert_eq!(
522 store.get_subscription_cursor("mob-a").await.unwrap(),
523 None,
524 "fresh store should have no cursor",
525 );
526 store.set_subscription_cursor("mob-a", 42).await.unwrap();
527 assert_eq!(
528 store.get_subscription_cursor("mob-a").await.unwrap(),
529 Some(42),
530 );
531 assert_eq!(store.get_subscription_cursor("mob-b").await.unwrap(), None,);
533 }
534
535 #[tokio::test]
536 async fn in_memory_persistent_store_overwrite() {
537 let store = InMemoryMetadataStore::new();
538 store.set_subscription_cursor("m", 1).await.unwrap();
539 store.set_subscription_cursor("m", 2).await.unwrap();
540 assert_eq!(store.get_subscription_cursor("m").await.unwrap(), Some(2),);
541 }
542
543 #[tokio::test]
544 async fn sqlite_persistent_store_round_trip() {
545 let store = SqliteMetadataStore::in_memory().unwrap();
546 assert_eq!(store.get_subscription_cursor("mob-a").await.unwrap(), None,);
547 store.set_subscription_cursor("mob-a", 1234).await.unwrap();
548 assert_eq!(
549 store.get_subscription_cursor("mob-a").await.unwrap(),
550 Some(1234),
551 );
552 store.set_subscription_cursor("mob-a", 9999).await.unwrap();
554 assert_eq!(
555 store.get_subscription_cursor("mob-a").await.unwrap(),
556 Some(9999),
557 );
558 store.set_subscription_cursor("mob-b", 5).await.unwrap();
560 assert_eq!(
561 store.get_subscription_cursor("mob-a").await.unwrap(),
562 Some(9999),
563 );
564 assert_eq!(
565 store.get_subscription_cursor("mob-b").await.unwrap(),
566 Some(5),
567 );
568 }
569
570 #[tokio::test]
571 async fn sqlite_store_persists_across_handles() {
572 let dir = tempfile::tempdir().unwrap();
577 let path = dir.path().join("mobkit-metadata.sqlite");
578 {
579 let store = SqliteMetadataStore::open(&path).unwrap();
580 store.set_subscription_cursor("mob-x", 7777).await.unwrap();
581 }
582 let store = SqliteMetadataStore::open(&path).unwrap();
584 assert_eq!(
585 store.get_subscription_cursor("mob-x").await.unwrap(),
586 Some(7777),
587 "cursor should survive handle drop",
588 );
589 }
590
591 #[tokio::test]
592 async fn fresh_store_stamps_mobkit_metadata_domain() {
593 let dir = tempfile::tempdir().unwrap();
594 let path = dir.path().join("mobkit-metadata.sqlite");
595 let store = SqliteMetadataStore::open(&path).unwrap();
596 store.set_subscription_cursor("mob-a", 1).await.unwrap();
597 let probe = Connection::open(&path).unwrap();
598 assert_eq!(
599 meerkat_sqlite::domain_version(&probe, "mobkit-metadata").unwrap(),
600 Some(1)
601 );
602 }
603
604 #[tokio::test]
610 async fn legacy_metadata_file_is_refused_with_rows_preserved() {
611 let dir = tempfile::tempdir().unwrap();
612 let path = dir.path().join("mobkit-metadata.sqlite");
613 {
614 let conn = Connection::open(&path).unwrap();
615 conn.execute_batch(
616 "CREATE TABLE mobkit_metadata (
617 mob_id TEXT NOT NULL,
618 key TEXT NOT NULL,
619 value TEXT NOT NULL,
620 PRIMARY KEY (mob_id, key)
621 );
622 INSERT INTO mobkit_metadata (mob_id, key, value)
623 VALUES ('mob-legacy', 'subscription_cursor', '314');",
624 )
625 .unwrap();
626 }
627 assert!(
628 SqliteMetadataStore::open(&path).is_err(),
629 "opening a pre-ledger metadata database must refuse typed: unledgered owned \
630 tables are below the mobkit 0.8.8 floor and must never be silently converged"
631 );
632 let probe = Connection::open(&path).unwrap();
633 let preserved: String = probe
634 .query_row(
635 "SELECT value FROM mobkit_metadata \
636 WHERE mob_id = 'mob-legacy' AND key = 'subscription_cursor'",
637 [],
638 |row| row.get(0),
639 )
640 .unwrap();
641 assert_eq!(
642 preserved, "314",
643 "the refusal must leave legacy rows untouched"
644 );
645 assert_eq!(
646 meerkat_sqlite::domain_version(&probe, "mobkit-metadata").unwrap(),
647 None,
648 "a refused open must not stamp the ledger"
649 );
650 }
651
652 #[tokio::test]
657 async fn metadata_and_mob_domains_cotenant_one_file() {
658 let dir = tempfile::tempdir().unwrap();
659 let path = dir.path().join("mob.sqlite3");
660 let _mob = meerkat_mob::MobStorage::persistent(&path).expect("mob storage");
661 let store = SqliteMetadataStore::open(&path).expect("metadata store");
662 store.set_subscription_cursor("mob-a", 42).await.unwrap();
663 assert_eq!(
664 store.get_subscription_cursor("mob-a").await.unwrap(),
665 Some(42)
666 );
667 let probe = Connection::open(&path).unwrap();
668 let mob_version = meerkat_sqlite::domain_version(&probe, "mob").unwrap();
669 assert!(
670 mob_version.is_some_and(|version| version >= 1),
671 "meerkat-mob's own domain row must be present: {mob_version:?}"
672 );
673 assert_eq!(
674 meerkat_sqlite::domain_version(&probe, "mobkit-metadata").unwrap(),
675 Some(1),
676 "mobkit's domain row must coexist with meerkat-mob's"
677 );
678 }
679
680 #[tokio::test]
681 async fn run_scope_distinguishes_mobs() {
682 let table = RuntimeMetadataTable::new();
683 let scope_a = MetadataScope::Run("mob-a".to_string(), "run-1".to_string());
684 let scope_b = MetadataScope::Run("mob-b".to_string(), "run-1".to_string());
685 table
686 .set_labels(scope_a.clone(), labels(&[("k", "a")]))
687 .await;
688 table
689 .set_labels(scope_b.clone(), labels(&[("k", "b")]))
690 .await;
691 assert_eq!(
692 table
693 .get_labels(&scope_a)
694 .await
695 .get("k")
696 .map(String::as_str),
697 Some("a")
698 );
699 assert_eq!(
700 table
701 .get_labels(&scope_b)
702 .await
703 .get("k")
704 .map(String::as_str),
705 Some("b")
706 );
707 }
708}