Skip to main content

meerkat_mobkit/runtime/
metadata.rs

1//! Mobkit-side sidecar table for mob- and run-level labels.
2//!
3//! Member-level labels are owned by `meerkat-mob` (they flow through
4//! `SpawnMemberSpec.with_labels()` and out via `MobMemberListEntry.labels`).
5//! Mob-level and run-level labels — for associating an external context like
6//! `repo`, `branch`, `customer`, `deployment`, or `environment` with a mob or
7//! a flow run — have nowhere to live in the upstream model. This module owns
8//! that side table.
9//!
10//! For v1 the table is in-memory only. Persistence behind `MobStorage` is a
11//! future enhancement; restarts wipe the labels. The table is keyed by
12//! [`MetadataScope`] so the same surface can serve mobs and runs uniformly.
13
14use 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/// Scope of a label set.
24///
25/// Mob scope holds labels keyed by `mob_id`; run scope holds labels keyed by
26/// `(mob_id, run_id)`. The mob id is part of the run scope so two mobs with
27/// overlapping run identifiers stay isolated.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum MetadataScope {
30    Mob(String),
31    Run(String, String),
32}
33
34impl MetadataScope {
35    /// Return the mob id this scope belongs to.
36    pub fn mob_id(&self) -> &str {
37        match self {
38            Self::Mob(mob) => mob,
39            Self::Run(mob, _) => mob,
40        }
41    }
42
43    /// Return the run id, if this scope is run-scoped.
44    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/// In-memory label table keyed by [`MetadataScope`].
53///
54/// Operations replace label sets wholesale (no merge). Callers wanting
55/// merge semantics should read first, mutate the map, then write it back.
56#[derive(Debug, Clone, Default)]
57pub struct RuntimeMetadataTable {
58    inner: Arc<RwLock<BTreeMap<MetadataScope, BTreeMap<String, String>>>>,
59}
60
61impl RuntimeMetadataTable {
62    /// Create an empty table.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Replace the label set for `scope`. An empty `labels` map clears
68    /// the entry.
69    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    /// Return the label set for `scope`, or an empty map if none is set.
79    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    /// Remove the label set for `scope`. Returns the previous value if any.
85    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    /// Return all label sets associated with a mob — both the mob-scoped
91    /// entry (if any) and every run-scoped entry whose mob id matches.
92    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
105/// Parse a JSON `labels` field as a string→string map.
106///
107/// Accepts a missing field, `null`, or an empty object — all yield an empty
108/// map. Anything else must deserialize cleanly or returns a human-readable
109/// error string suitable for a JSON-RPC `Invalid params` reply.
110pub 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
118/// Render a label map as a JSON object suitable for the wire format.
119pub 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
127/// Outcome of dispatching a label RPC against a [`RuntimeMetadataTable`].
128///
129/// Both transports (the unified-runtime JSON-RPC and the HTTP-console JSON-RPC)
130/// project this into their own response envelope.
131pub enum LabelRpcResult {
132    /// `set` / `delete`: returns `{"accepted": true}`.
133    Accepted,
134    /// `get`: returns `{"labels": {...}}`.
135    Labels(BTreeMap<String, String>),
136    /// Validation error — `Invalid params: <message>`.
137    InvalidParams(String),
138}
139
140/// Replace the label set for `scope`, parsing `labels` from RPC params.
141pub 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
155/// Read the label set for `scope`.
156pub async fn dispatch_labels_get(
157    table: &RuntimeMetadataTable,
158    scope: MetadataScope,
159) -> LabelRpcResult {
160    LabelRpcResult::Labels(table.get_labels(&scope).await)
161}
162
163/// Remove the label set for `scope`.
164pub 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
172/// Pull a non-empty `run_id` string from RPC params.
173pub 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// ---------------------------------------------------------------------------
181// Persistent metadata adapter
182// ---------------------------------------------------------------------------
183//
184// Distinct from the in-memory `RuntimeMetadataTable` above. The label sidecar
185// resets on restart (acceptable — labels are app-injected runtime metadata).
186// The structural-events subscription cursor must survive restart so a
187// restarted gateway resumes from where it left off rather than dropping
188// events emitted between processes. This adapter owns that durable state.
189
190/// Errors raised by [`PersistentMetadataStore`] implementations.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum MetadataStoreError {
193    /// Underlying I/O or storage failure (sqlite open, schema, query, ...).
194    Io(String),
195    /// A persisted value couldn't be parsed back into the typed shape — the
196    /// store was probably written by a future mobkit version.
197    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/// Persistent storage for mobkit runtime metadata that must survive a
212/// gateway restart — currently the structural-events subscription cursor.
213///
214/// Two impls live in this module: [`InMemoryMetadataStore`] (no
215/// persistence; used when no SQLite mob storage is configured) and
216/// [`SqliteMetadataStore`] (writes a small `mobkit_metadata` table next
217/// to the mob's own SQLite store). The `UnifiedRuntime` builder picks
218/// the impl based on the configured `MobBootstrapSpec`.
219#[async_trait]
220pub trait PersistentMetadataStore: Send + Sync {
221    /// Read the last-projected mob events cursor for `mob_id`. Returns
222    /// `Ok(None)` when no cursor has been written yet (fresh deploy or
223    /// in-memory deployment that just started).
224    async fn get_subscription_cursor(
225        &self,
226        mob_id: &str,
227    ) -> Result<Option<u64>, MetadataStoreError>;
228
229    /// Persist the last-projected mob events cursor for `mob_id`.
230    async fn set_subscription_cursor(
231        &self,
232        mob_id: &str,
233        cursor: u64,
234    ) -> Result<(), MetadataStoreError>;
235}
236
237/// In-memory persistent metadata store.
238///
239/// "Persistent" is aspirational here — the values survive `Arc<...>` clones
240/// but reset to empty on process restart. Used when no SQLite mob storage
241/// is configured. The structural-events subscription falls back to "start
242/// at latest" on restart in this case, which is the right behaviour:
243/// in-memory deployments don't have a persistent ledger to replay against
244/// either.
245#[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
278/// SQLite-backed persistent metadata store.
279///
280/// Opens its own `rusqlite::Connection` to the supplied database path —
281/// the same path the mob's `MobStorage` uses, but with a separate handle.
282/// Cross-handle access is safe; meerkat #445's `notify`-based event-store
283/// watcher already runs in this configuration. The `mobkit_metadata`
284/// table is independent of meerkat-mob's own schema, so opening order
285/// doesn't matter; in the shared file's migration ledger the table lives
286/// under mobkit's own `mobkit-metadata` domain, co-tenanting meerkat-mob's
287/// `mob` domain (the ledger keys strictly by domain name, so the two
288/// crates stamp and migrate independently).
289///
290/// Schema:
291/// ```text
292/// CREATE TABLE mobkit_metadata (
293///     mob_id  TEXT NOT NULL,
294///     key     TEXT NOT NULL,
295///     value   TEXT NOT NULL,
296///     PRIMARY KEY (mob_id, key)
297/// )
298/// ```
299///
300/// The subscription cursor lives at `key = "subscription_cursor"`,
301/// stored as a base-10 string for simple human inspection. Future
302/// metadata fields land here under their own keys.
303pub struct SqliteMetadataStore {
304    conn: Mutex<Connection>,
305    /// Database file path; `:memory:` for in-memory stores (where the
306    /// per-operation fence guard degrades to a no-op).
307    db_path: PathBuf,
308}
309
310const SUBSCRIPTION_CURSOR_KEY: &str = "subscription_cursor";
311
312/// The runtime-metadata store's schema domain in the per-file migration
313/// ledger. Migration 0001 is the historical one-table DDL.
314const 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    /// Open (or create) a SQLite metadata store at `path`.
344    ///
345    /// `path` should typically be the same database the mob's `MobStorage`
346    /// uses; the table is `mobkit_metadata` and won't collide with
347    /// meerkat-mob's own tables.
348    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    /// Open an in-memory SQLite store (for tests).
361    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    /// Per-operation maintenance-fence guard: the connection is held for
373    /// the store's lifetime, so the fence cannot ride the open — every
374    /// operation takes its own shared guard.
375    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    // ----- PersistentMetadataStore tests --------------------------------
517
518    #[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        // Per-mob isolation.
532        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        // Overwrite via UPSERT.
553        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        // Per-mob isolation.
559        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        // The whole point of SQLite-backed persistence: a fresh handle to
573        // the same DB sees writes from the previous handle. We can't drop
574        // and reopen an in-memory DB (it disappears with the connection),
575        // so write to a tempfile, drop, reopen.
576        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        // Reopen.
583        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    /// A pre-ledger file (bare mobkit_metadata table, no meerkat_schema row)
605    /// is refused typed at open with its rows left untouched and no ledger
606    /// stamped: pre-ledger corpora are below the mobkit 0.8.8 floor, and the
607    /// 0.8.11 reset retired silent pre-floor convergence (this test pinned
608    /// that convergence until then).
609    #[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    /// The metadata table co-tenants the same database file as meerkat-mob's
653    /// MobStorage. The per-file ledger keys strictly by domain, so the two
654    /// crates' domains (`mob` and `mobkit-metadata`) must coexist in one
655    /// `meerkat_schema` table without clobbering each other.
656    #[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}