1use std::collections::{HashMap, HashSet};
52
53use rusqlite::{params, params_from_iter, types::Value, Row};
54
55use super::error::StoreError;
56use super::store::Store;
57
58pub trait ManagedResource: Sized {
61 const TABLE: &'static str;
63 const REF_COL: &'static str;
65 const AGENT_REF_TABLE: &'static str;
67 const BUNDLE_REF_TABLE: &'static str;
69 const COLUMNS: &'static [&'static str];
73 const UPDATE_COLUMNS: &'static [&'static str];
76 const NAME_NOUN: &'static str;
78 const ARTICLE_NOUN: &'static str;
80 const KIND: &'static str;
82
83 fn from_row(row: &Row<'_>) -> rusqlite::Result<Self>;
85 fn values(&self) -> Vec<Value>;
87 fn id(&self) -> &str;
88 fn name(&self) -> &str;
89 fn is_global(&self) -> bool;
94 fn updated_at(&self) -> i64;
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum Owner {
101 Agent,
102 Bundle,
103}
104
105impl Owner {
106 fn ref_table<R: ManagedResource>(self) -> &'static str {
107 match self {
108 Owner::Agent => R::AGENT_REF_TABLE,
109 Owner::Bundle => R::BUNDLE_REF_TABLE,
110 }
111 }
112 fn key_col(self) -> &'static str {
113 match self {
114 Owner::Agent => "agent_id",
115 Owner::Bundle => "bundle_id",
116 }
117 }
118 fn word(self) -> &'static str {
119 match self {
120 Owner::Agent => "agent",
121 Owner::Bundle => "bundle",
122 }
123 }
124}
125
126fn placeholders(n: usize) -> String {
128 (1..=n).map(|i| format!("?{i}")).collect::<Vec<_>>().join(", ")
129}
130
131fn placeholders_from(start: usize, n: usize) -> String {
135 (start..start + n).map(|i| format!("?{i}")).collect::<Vec<_>>().join(", ")
136}
137
138fn update_set<R: ManagedResource>() -> String {
139 R::UPDATE_COLUMNS
140 .iter()
141 .map(|c| format!("{c}=excluded.{c}"))
142 .collect::<Vec<_>>()
143 .join(", ")
144}
145
146fn values_with_global<R: ManagedResource>(row: &R, force_global: Option<bool>) -> Vec<Value> {
149 let mut v = row.values();
150 if let Some(g) = force_global {
151 let idx = R::COLUMNS
152 .iter()
153 .position(|c| *c == "is_global")
154 .expect("ManagedResource::COLUMNS must contain is_global");
155 v[idx] = Value::Integer(if g { 1 } else { 0 });
156 }
157 v
158}
159
160impl Store {
161 fn managed_bound_ids<R: ManagedResource>(&self, owner: Owner, owner_id: &str) -> Result<HashSet<String>, StoreError> {
173 let conn = self.conn.lock().unwrap();
174 let sql = format!(
175 "SELECT {refc} FROM {reft} WHERE {key} = ?1",
176 refc = R::REF_COL,
177 reft = owner.ref_table::<R>(),
178 key = owner.key_col(),
179 );
180 let mut stmt = conn.prepare(&sql)?;
181 let rows = stmt.query_map(params![owner_id], |row| row.get::<_, String>(0))?;
182 let mut out = HashSet::new();
183 for r in rows {
184 out.insert(r?);
185 }
186 Ok(out)
187 }
188
189 fn managed_bound_counts<R: ManagedResource>(&self) -> Result<HashMap<String, i64>, StoreError> {
191 let conn = self.conn.lock().unwrap();
192 let sql = format!(
193 "SELECT {refc}, COUNT(*) FROM {reft} GROUP BY {refc}",
194 refc = R::REF_COL,
195 reft = R::AGENT_REF_TABLE,
196 );
197 let mut stmt = conn.prepare(&sql)?;
198 let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?;
199 let mut out = HashMap::new();
200 for r in rows {
201 let (id, count) = r?;
202 out.insert(id, count);
203 }
204 Ok(out)
205 }
206
207 fn managed_catalog_global<R: ManagedResource>(&self) -> Result<Vec<R>, StoreError> {
210 let sql = format!(
211 "SELECT {cols} FROM {table} WHERE is_global = 1 ORDER BY updated_at DESC",
212 cols = R::COLUMNS.join(", "),
213 table = R::TABLE,
214 );
215 let conn = self.conn.lock().unwrap();
216 let mut stmt = conn.prepare(&sql)?;
217 let rows = stmt.query_map([], |row| R::from_row(row))?;
218 let mut out = Vec::new();
219 for r in rows {
220 out.push(r?);
221 }
222 Ok(out)
223 }
224
225 fn managed_catalog_global_and_ids<R: ManagedResource>(&self, ids: &HashSet<String>) -> Result<Vec<R>, StoreError> {
229 let sql = if ids.is_empty() {
230 format!(
231 "SELECT {cols} FROM {table} WHERE is_global = 1",
232 cols = R::COLUMNS.join(", "),
233 table = R::TABLE,
234 )
235 } else {
236 format!(
237 "SELECT {cols} FROM {table} WHERE is_global = 1 OR id IN ({ph})",
238 cols = R::COLUMNS.join(", "),
239 table = R::TABLE,
240 ph = placeholders(ids.len()),
241 )
242 };
243 let conn = self.conn.lock().unwrap();
244 let mut stmt = conn.prepare(&sql)?;
245 let bind: Vec<Value> = ids.iter().map(|s| Value::Text(s.clone())).collect();
246 let rows = stmt.query_map(params_from_iter(bind), |row| R::from_row(row))?;
247 let mut out = Vec::new();
248 for r in rows {
249 out.push(r?);
250 }
251 Ok(out)
252 }
253
254 pub(super) fn managed_list<R: ManagedResource>(
260 &self,
261 catalog: &Store,
262 owner: Owner,
263 owner_id: &str,
264 ) -> Result<Vec<(R, bool)>, StoreError> {
265 let bound_ids = self.managed_bound_ids::<R>(owner, owner_id)?;
266 let mut rows = catalog.managed_catalog_global_and_ids::<R>(&bound_ids)?;
267 rows.sort_by(|a, b| b.is_global().cmp(&a.is_global()).then_with(|| b.updated_at().cmp(&a.updated_at())));
270 Ok(rows
271 .into_iter()
272 .map(|row| {
273 let bound = bound_ids.contains(row.id());
274 (row, bound)
275 })
276 .collect())
277 }
278
279 pub(super) fn managed_list_global_for_agent<R: ManagedResource>(
284 &self,
285 catalog: &Store,
286 agent_id: &str,
287 ) -> Result<Vec<(R, bool)>, StoreError> {
288 let globals = catalog.managed_catalog_global::<R>()?;
289 let bound_ids = self.managed_bound_ids::<R>(Owner::Agent, agent_id)?;
290 Ok(globals
291 .into_iter()
292 .map(|row| {
293 let bound = bound_ids.contains(row.id());
294 (row, bound)
295 })
296 .collect())
297 }
298
299 pub(super) fn managed_list_global<R: ManagedResource>(&self, catalog: &Store) -> Result<Vec<(R, i64)>, StoreError> {
302 let globals = catalog.managed_catalog_global::<R>()?;
303 let counts = self.managed_bound_counts::<R>()?;
304 Ok(globals
305 .into_iter()
306 .map(|row| {
307 let count = counts.get(row.id()).copied().unwrap_or(0);
308 (row, count)
309 })
310 .collect())
311 }
312
313 pub(super) fn managed_get<R: ManagedResource>(&self, id: &str) -> Result<Option<R>, StoreError> {
316 let sql = format!(
317 "SELECT {cols} FROM {table} WHERE id = ?1",
318 cols = R::COLUMNS.join(", "),
319 table = R::TABLE,
320 );
321 let conn = self.conn.lock().unwrap();
322 match conn.query_row(&sql, params![id], |row| R::from_row(row)) {
323 Ok(r) => Ok(Some(r)),
324 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
325 Err(e) => Err(StoreError::Sqlite(e)),
326 }
327 }
328
329 pub(super) fn managed_delete<R: ManagedResource>(&self, catalog: &Store, id: &str) -> Result<bool, StoreError> {
351 let deleted = {
352 let conn = catalog.conn.lock().unwrap();
353 let rows = conn.execute(&format!("DELETE FROM {} WHERE id = ?1", R::TABLE), params![id])?;
354 rows > 0
355 };
356 {
357 let conn = self.conn.lock().unwrap();
358 conn.execute(&format!("DELETE FROM {} WHERE {} = ?1", R::AGENT_REF_TABLE, R::REF_COL), params![id])?;
359 conn.execute(&format!("DELETE FROM {} WHERE {} = ?1", R::BUNDLE_REF_TABLE, R::REF_COL), params![id])?;
360 }
361 Ok(deleted)
362 }
363
364 pub(super) fn managed_unbind_all_for_bundle<R: ManagedResource>(
376 &self,
377 bundle_id: &str,
378 ) -> Result<usize, StoreError> {
379 let conn = self.conn.lock().unwrap();
380 let rows = conn.execute(
381 &format!(
382 "DELETE FROM {} WHERE {} = ?1",
383 R::BUNDLE_REF_TABLE,
384 Owner::Bundle.key_col()
385 ),
386 params![bundle_id],
387 )?;
388 Ok(rows)
389 }
390
391 pub(super) fn managed_bind_agent<R: ManagedResource>(
410 &self,
411 catalog: &Store,
412 agent_id: &str,
413 id: &str,
414 ) -> Result<(), StoreError> {
415 let agent_exists: bool = {
416 let conn = self.conn.lock().unwrap();
417 conn.query_row(
418 "SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)",
419 params![agent_id],
420 |row| row.get(0),
421 )?
422 };
423 if !agent_exists {
424 return Err(StoreError::Other(format!(
425 "agent {agent_id} not found in this channel's local registry — cross-channel {} binding is not supported",
426 R::KIND
427 )));
428 }
429 if catalog.managed_get::<R>(id)?.is_none() {
430 return Err(StoreError::Other(format!(
431 "{} {id} not found — cannot bind it to agent {agent_id}",
432 R::NAME_NOUN
433 )));
434 }
435 let conn = self.conn.lock().unwrap();
436 conn.execute(
437 &format!(
438 "INSERT OR IGNORE INTO {} (agent_id, {}) VALUES (?1, ?2)",
439 R::AGENT_REF_TABLE,
440 R::REF_COL
441 ),
442 params![agent_id, id],
443 )?;
444 Ok(())
445 }
446
447 pub(super) fn managed_bind_bundle<R: ManagedResource>(
462 &self,
463 catalog: &Store,
464 id_store: &Store,
465 bundle_id: &str,
466 id: &str,
467 ) -> Result<(), StoreError> {
468 if !id_store.bundle_exists(bundle_id)? {
469 return Err(StoreError::Other(format!(
470 "bundle {bundle_id} not found — cannot bind {} to a nonexistent bundle",
471 R::ARTICLE_NOUN
472 )));
473 }
474 if catalog.managed_get::<R>(id)?.is_none() {
475 return Err(StoreError::Other(format!(
476 "{} {id} not found — cannot bind it to bundle {bundle_id}",
477 R::NAME_NOUN
478 )));
479 }
480 let conn = self.conn.lock().unwrap();
481 conn.execute(
482 &format!(
483 "INSERT OR IGNORE INTO {} (bundle_id, {}) VALUES (?1, ?2)",
484 R::BUNDLE_REF_TABLE,
485 R::REF_COL
486 ),
487 params![bundle_id, id],
488 )?;
489 Ok(())
490 }
491
492 fn bundle_exists(&self, bundle_id: &str) -> Result<bool, StoreError> {
493 let conn = self.conn.lock().unwrap();
494 Ok(conn.query_row(
495 "SELECT EXISTS(SELECT 1 FROM db_bundles WHERE id = ?1)",
496 params![bundle_id],
497 |row| row.get(0),
498 )?)
499 }
500
501 pub(super) fn managed_unbind<R: ManagedResource>(
503 &self,
504 owner: Owner,
505 owner_id: &str,
506 id: &str,
507 ) -> Result<bool, StoreError> {
508 let conn = self.conn.lock().unwrap();
509 let rows = conn.execute(
510 &format!(
511 "DELETE FROM {} WHERE {} = ?1 AND {} = ?2",
512 owner.ref_table::<R>(),
513 owner.key_col(),
514 R::REF_COL
515 ),
516 params![owner_id, id],
517 )?;
518 Ok(rows > 0)
519 }
520
521 pub(super) fn managed_upsert_unique<R: ManagedResource>(
540 &self,
541 catalog: &Store,
542 owner: Owner,
543 owner_id: &str,
544 row: &R,
545 bind_new: bool,
546 force_global: Option<bool>,
547 ) -> Result<(), StoreError> {
548 let bound_ids = self.managed_bound_ids::<R>(owner, owner_id)?;
549
550 let dup_sql = if bound_ids.is_empty() {
551 format!(
552 "SELECT COUNT(*) FROM {table} WHERE name = ?1 AND id <> ?2 AND is_global = 1",
553 table = R::TABLE,
554 )
555 } else {
556 format!(
557 "SELECT COUNT(*) FROM {table} WHERE name = ?1 AND id <> ?2 AND (is_global = 1 OR id IN ({ph}))",
558 table = R::TABLE,
559 ph = placeholders_from(3, bound_ids.len()),
560 )
561 };
562 let dup: i64 = {
563 let conn = catalog.conn.lock().unwrap();
564 let mut bind: Vec<Value> = vec![Value::Text(row.name().to_string()), Value::Text(row.id().to_string())];
565 bind.extend(bound_ids.iter().map(|s| Value::Text(s.clone())));
566 conn.query_row(&dup_sql, params_from_iter(bind), |r| r.get(0))?
567 };
568 if dup > 0 {
569 return Err(StoreError::Other(format!(
570 "{} name '{}' already bound to this {}",
571 R::NAME_NOUN,
572 row.name(),
573 owner.word()
574 )));
575 }
576
577 {
578 let conn = catalog.conn.lock().unwrap();
579 conn.execute(&insert_sql::<R>(), params_from_iter(values_with_global(row, force_global)))?;
580 }
581
582 if bind_new {
583 let bind_result = {
584 let conn = self.conn.lock().unwrap();
585 conn.execute(
586 &format!(
587 "INSERT OR IGNORE INTO {} ({}, {}) VALUES (?1, ?2)",
588 owner.ref_table::<R>(),
589 owner.key_col(),
590 R::REF_COL
591 ),
592 params![owner_id, row.id()],
593 )
594 };
595 if let Err(bind_err) = bind_result {
596 let conn = catalog.conn.lock().unwrap();
597 if let Err(compensate_err) =
598 conn.execute(&format!("DELETE FROM {} WHERE id = ?1", R::TABLE), params![row.id()])
599 {
600 tracing::warn!(
601 id = row.id(),
602 bind_error = %bind_err,
603 compensate_error = %compensate_err,
604 "managed_upsert_unique: compensating delete failed after a bind error — a durable orphan catalog row may remain"
605 );
606 }
607 return Err(bind_err.into());
608 }
609 }
610 Ok(())
611 }
612
613 pub(super) fn managed_upsert_unique_for_bundle<R: ManagedResource>(
622 &self,
623 catalog: &Store,
624 id_store: &Store,
625 bundle_id: &str,
626 row: &R,
627 bind_new: bool,
628 ) -> Result<(), StoreError> {
629 if !id_store.bundle_exists(bundle_id)? {
630 return Err(StoreError::Other(format!(
631 "bundle {bundle_id} not found — cannot create {} for a nonexistent bundle",
632 R::ARTICLE_NOUN
633 )));
634 }
635 self.managed_upsert_unique(catalog, Owner::Bundle, bundle_id, row, bind_new, Some(false))
636 }
637
638 pub(super) fn managed_upsert_unique_global<R: ManagedResource>(&self, row: &R) -> Result<(), StoreError> {
645 let mut conn = self.conn.lock().unwrap();
646 let tx = conn.transaction()?;
647 let dup: i64 = tx.query_row(
648 &format!(
649 "SELECT COUNT(*) FROM {} WHERE name = ?1 AND id <> ?2 AND is_global = 1",
650 R::TABLE
651 ),
652 params![row.name(), row.id()],
653 |r| r.get(0),
654 )?;
655 if dup > 0 {
656 return Err(StoreError::Other(format!(
657 "a global {} named '{}' already exists",
658 R::NAME_NOUN,
659 row.name()
660 )));
661 }
662 tx.execute(&insert_sql::<R>(), params_from_iter(values_with_global(row, Some(true))))?;
663 tx.commit()?;
664 Ok(())
665 }
666
667 pub(super) fn managed_is_accessible_to<R: ManagedResource>(
670 &self,
671 catalog: &Store,
672 owner: Owner,
673 owner_id: &str,
674 id: &str,
675 ) -> Result<bool, StoreError> {
676 let is_global: Option<bool> = {
677 let conn = catalog.conn.lock().unwrap();
678 match conn.query_row(
679 &format!("SELECT is_global FROM {} WHERE id = ?1", R::TABLE),
680 params![id],
681 |r| r.get::<_, i64>(0),
682 ) {
683 Ok(v) => Some(v != 0),
684 Err(rusqlite::Error::QueryReturnedNoRows) => None,
685 Err(e) => return Err(e.into()),
686 }
687 };
688 match is_global {
689 None => Ok(false),
690 Some(true) => Ok(true),
691 Some(false) => {
692 let conn = self.conn.lock().unwrap();
693 let count: i64 = conn.query_row(
694 &format!(
695 "SELECT COUNT(*) FROM {} WHERE {} = ?1 AND {} = ?2",
696 owner.ref_table::<R>(),
697 owner.key_col(),
698 R::REF_COL
699 ),
700 params![owner_id, id],
701 |r| r.get(0),
702 )?;
703 Ok(count > 0)
704 }
705 }
706 }
707
708 pub(super) fn managed_is_bound_to<R: ManagedResource>(
712 &self,
713 owner: Owner,
714 owner_id: &str,
715 id: &str,
716 ) -> Result<bool, StoreError> {
717 let conn = self.conn.lock().unwrap();
718 let count: i64 = conn.query_row(
719 &format!(
720 "SELECT COUNT(*) FROM {} WHERE {} = ?1 AND {} = ?2",
721 owner.ref_table::<R>(),
722 owner.key_col(),
723 R::REF_COL
724 ),
725 params![owner_id, id],
726 |r| r.get(0),
727 )?;
728 Ok(count > 0)
729 }
730
731 pub(super) fn managed_union_bundle_refs<R: ManagedResource>(
740 &self,
741 catalog: &Store,
742 agent_id: &str,
743 visible: &mut Vec<R>,
744 ) {
745 for bundle_id in self.agent_bundle_chain(agent_id) {
746 for (row, _) in self
747 .managed_list::<R>(catalog, Owner::Bundle, &bundle_id)
748 .unwrap_or_default()
749 {
750 if !visible.iter().any(|s| s.id() == row.id()) {
751 visible.push(row);
752 }
753 }
754 }
755 }
756}
757
758fn insert_sql<R: ManagedResource>() -> String {
759 format!(
760 "INSERT INTO {table} ({cols})
761 VALUES ({vals})
762 ON CONFLICT(id) DO UPDATE SET {set}",
763 table = R::TABLE,
764 cols = R::COLUMNS.join(", "),
765 vals = placeholders(R::COLUMNS.len()),
766 set = update_set::<R>(),
767 )
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 struct Probe;
775 impl ManagedResource for Probe {
776 const TABLE: &'static str = "db_probe";
777 const REF_COL: &'static str = "probe_id";
778 const AGENT_REF_TABLE: &'static str = "db_agent_probe_ref";
779 const BUNDLE_REF_TABLE: &'static str = "db_bundle_probe_ref";
780 const COLUMNS: &'static [&'static str] = &["id", "name", "is_global", "created_at", "updated_at"];
781 const UPDATE_COLUMNS: &'static [&'static str] = &["name", "updated_at"];
782 const NAME_NOUN: &'static str = "probe";
783 const ARTICLE_NOUN: &'static str = "a probe";
784 const KIND: &'static str = "probe";
785 fn from_row(_row: &Row<'_>) -> rusqlite::Result<Self> {
786 Ok(Probe)
787 }
788 fn values(&self) -> Vec<Value> {
789 vec![
790 Value::Text("id".into()),
791 Value::Text("n".into()),
792 Value::Integer(0),
793 Value::Integer(1),
794 Value::Integer(2),
795 ]
796 }
797 fn id(&self) -> &str {
798 "id"
799 }
800 fn name(&self) -> &str {
801 "n"
802 }
803 fn is_global(&self) -> bool {
804 false
805 }
806 fn updated_at(&self) -> i64 {
807 2
808 }
809 }
810
811 #[test]
812 fn insert_sql_lists_every_column_once_and_updates_only_the_editable_ones() {
813 let sql = insert_sql::<Probe>();
814 assert!(sql.contains("INSERT INTO db_probe (id, name, is_global, created_at, updated_at)"));
815 assert!(sql.contains("VALUES (?1, ?2, ?3, ?4, ?5)"));
816 assert!(sql.contains("ON CONFLICT(id) DO UPDATE SET name=excluded.name, updated_at=excluded.updated_at"));
817 assert!(!sql.contains("is_global=excluded"), "is_global must never be rewritten on conflict");
818 }
819
820 #[test]
821 fn force_global_replaces_only_the_is_global_value() {
822 let forced = values_with_global(&Probe, Some(true));
823 assert_eq!(forced[2], Value::Integer(1));
824 assert_eq!(forced[0], Value::Text("id".into()));
825 let cleared = values_with_global(&Probe, Some(false));
826 assert_eq!(cleared[2], Value::Integer(0));
827 assert_eq!(values_with_global(&Probe, None), Probe.values());
828 }
829
830 #[test]
831 fn owner_selects_the_matching_ref_table_and_key() {
832 assert_eq!(Owner::Agent.ref_table::<Probe>(), "db_agent_probe_ref");
833 assert_eq!(Owner::Bundle.ref_table::<Probe>(), "db_bundle_probe_ref");
834 assert_eq!(Owner::Agent.key_col(), "agent_id");
835 assert_eq!(Owner::Bundle.key_col(), "bundle_id");
836 }
837
838 #[test]
839 fn placeholders_from_continues_numbering_past_earlier_bound_params() {
840 assert_eq!(placeholders_from(3, 0), "");
841 assert_eq!(placeholders_from(3, 1), "?3");
842 assert_eq!(placeholders_from(3, 3), "?3, ?4, ?5");
843 }
844
845 use crate::backend::storage::skills::Skill;
856
857 fn mstore() -> Store {
858 Store::open_in_memory().unwrap()
859 }
860
861 fn catalog_store() -> Store {
862 let store = Store::open_in_memory().unwrap();
863 store.apply_identity_schema_for_tests().unwrap();
864 store
865 }
866
867 fn skill(id: &str, name: &str, is_global: bool, updated_at: i64) -> Skill {
868 Skill {
869 id: id.to_string(),
870 name: name.to_string(),
871 trigger: String::new(),
872 skill_type: "prompt".to_string(),
873 description: String::new(),
874 content: "c".to_string(),
875 is_global,
876 created_at: updated_at,
877 updated_at,
878 }
879 }
880
881 fn insert_agent(store: &Store, id: &str) {
882 store
883 .conn()
884 .lock()
885 .unwrap()
886 .execute("INSERT INTO db_agents (id, name, provider) VALUES (?1, 'A', 'claude')", params![id])
887 .unwrap();
888 }
889
890 fn insert_bundle(store: &Store, id: &str) {
891 store
892 .conn()
893 .lock()
894 .unwrap()
895 .execute(
896 "INSERT INTO db_bundles (id, name, created_at, updated_at) VALUES (?1, ?1, 0, 0)",
897 params![id],
898 )
899 .unwrap();
900 }
901
902 #[test]
903 fn managed_list_combines_global_and_bound_private_rows_from_two_distinct_stores() {
904 let mstore = mstore();
905 let catalog = catalog_store();
906 insert_agent(&mstore, "agent-1");
907 catalog.managed_upsert_unique_global(&skill("global-1", "Global", true, 200)).unwrap();
908 catalog.managed_upsert_unique_global(&skill("global-2", "Unrelated Global", true, 100)).unwrap();
909 {
912 let conn = catalog.conn().lock().unwrap();
913 conn.execute(
914 "INSERT INTO db_skills (id, name, is_global, created_at, updated_at) VALUES ('private-other', 'Someone Else', 0, 50, 50)",
915 [],
916 )
917 .unwrap();
918 conn.execute(
919 "INSERT INTO db_skills (id, name, is_global, created_at, updated_at) VALUES ('private-mine', 'Mine', 0, 150, 150)",
920 [],
921 )
922 .unwrap();
923 }
924 mstore.skill_bind(&catalog, "agent-1", "private-mine").unwrap();
925
926 let list = mstore.managed_list::<Skill>(&catalog, Owner::Agent, "agent-1").unwrap();
927 let ids: Vec<&str> = list.iter().map(|(s, _)| s.id.as_str()).collect();
928 assert_eq!(ids, vec!["global-1", "global-2", "private-mine"], "globals first (newest first), then the bound private row");
929 assert!(!list.iter().any(|(s, _)| s.id == "private-other"), "an unbound private row from another owner must not appear");
930
931 let bound: std::collections::HashMap<&str, bool> = list.iter().map(|(s, b)| (s.id.as_str(), *b)).collect();
932 assert!(!bound["global-1"], "a global the owner has not directly bound is visible but not 'bound'");
933 assert!(bound["private-mine"]);
934 }
935
936 #[test]
937 fn managed_list_global_reports_bound_count_from_the_mstore_side() {
938 let mstore = mstore();
939 let catalog = catalog_store();
940 insert_agent(&mstore, "agent-1");
941 insert_agent(&mstore, "agent-2");
942 catalog.managed_upsert_unique_global(&skill("global-1", "Global", true, 100)).unwrap();
943 mstore.skill_bind(&catalog, "agent-1", "global-1").unwrap();
944 mstore.skill_bind(&catalog, "agent-2", "global-1").unwrap();
945
946 let list = mstore.managed_list_global::<Skill>(&catalog).unwrap();
947 assert_eq!(list.len(), 1);
948 assert_eq!(list[0].1, 2, "bound_count must come from mstore's ref table, not catalog");
949 }
950
951 #[test]
952 fn managed_list_global_for_agent_never_returns_private_rows() {
953 let mstore = mstore();
954 let catalog = catalog_store();
955 insert_agent(&mstore, "agent-1");
956 catalog.managed_upsert_unique_global(&skill("global-1", "Global", true, 100)).unwrap();
957 {
958 let conn = catalog.conn().lock().unwrap();
959 conn.execute(
960 "INSERT INTO db_skills (id, name, is_global, created_at, updated_at) VALUES ('private-1', 'Private', 0, 50, 50)",
961 [],
962 )
963 .unwrap();
964 }
965 mstore.skill_bind(&catalog, "agent-1", "global-1").unwrap();
966
967 let list = mstore.managed_list_global_for_agent::<Skill>(&catalog, "agent-1").unwrap();
968 assert_eq!(list.len(), 1);
969 assert_eq!(list[0].0.id, "global-1");
970 assert!(list[0].1);
971 }
972
973 #[test]
974 fn managed_is_accessible_to_checks_globality_in_catalog_and_binding_in_mstore() {
975 let mstore = mstore();
976 let catalog = catalog_store();
977 insert_agent(&mstore, "agent-1");
978 catalog.managed_upsert_unique_global(&skill("global-1", "Global", true, 100)).unwrap();
979 {
980 let conn = catalog.conn().lock().unwrap();
981 conn.execute(
982 "INSERT INTO db_skills (id, name, is_global, created_at, updated_at) VALUES ('private-1', 'Private', 0, 50, 50)",
983 [],
984 )
985 .unwrap();
986 }
987
988 assert!(mstore.managed_is_accessible_to::<Skill>(&catalog, Owner::Agent, "agent-1", "global-1").unwrap());
989 assert!(!mstore.managed_is_accessible_to::<Skill>(&catalog, Owner::Agent, "agent-1", "private-1").unwrap());
990 mstore.skill_bind(&catalog, "agent-1", "private-1").unwrap();
991 assert!(mstore.managed_is_accessible_to::<Skill>(&catalog, Owner::Agent, "agent-1", "private-1").unwrap());
992 assert!(!mstore.managed_is_accessible_to::<Skill>(&catalog, Owner::Agent, "agent-1", "no-such-id").unwrap());
993 }
994
995 #[test]
996 fn managed_upsert_unique_detects_a_duplicate_name_in_the_catalog_store() {
997 let mstore = mstore();
998 let catalog = catalog_store();
999 insert_agent(&mstore, "agent-1");
1000 catalog.managed_upsert_unique_global(&skill("global-1", "Deploy", true, 100)).unwrap();
1001
1002 let result = mstore.managed_upsert_unique(&catalog, Owner::Agent, "agent-1", &skill("new-1", "Deploy", false, 200), true, None);
1003 assert!(result.is_err(), "a name already used by a visible global row must be rejected");
1004 assert!(catalog.managed_get::<Skill>("new-1").unwrap().is_none());
1006 }
1007
1008 #[test]
1009 fn managed_upsert_unique_compensates_by_deleting_the_catalog_row_when_the_bind_fails() {
1010 let mstore = mstore();
1011 let catalog = catalog_store();
1012 insert_agent(&mstore, "agent-1");
1013 mstore.conn().lock().unwrap().execute_batch("DROP TABLE db_agent_skills_ref;").unwrap();
1021
1022 let result = mstore.managed_upsert_unique(&catalog, Owner::Agent, "agent-1", &skill("new-1", "Deploy", false, 200), true, None);
1023 assert!(result.is_err(), "the bind must fail now that its table is gone");
1024 assert!(
1025 catalog.managed_get::<Skill>("new-1").unwrap().is_none(),
1026 "the catalog insert must be compensated away, leaving no orphan row"
1027 );
1028 }
1029
1030 #[test]
1031 fn managed_upsert_unique_leaves_the_catalog_row_when_bind_new_is_false() {
1032 let mstore = mstore();
1033 let catalog = catalog_store();
1034 mstore.managed_upsert_unique(&catalog, Owner::Agent, "agent-1", &skill("new-1", "Deploy", false, 200), false, None).unwrap();
1035 assert!(catalog.managed_get::<Skill>("new-1").unwrap().is_some());
1036 }
1037
1038 #[test]
1039 fn managed_delete_purges_mstore_refs_and_removes_the_catalog_row() {
1040 let mstore = mstore();
1041 let catalog = catalog_store();
1042 insert_agent(&mstore, "agent-1");
1043 insert_bundle(&mstore, "bundle-1");
1044 catalog.managed_upsert_unique_global(&skill("global-1", "Global", true, 100)).unwrap();
1045 mstore.skill_bind(&catalog, "agent-1", "global-1").unwrap();
1046 mstore.bundle_skill_bind(&catalog, &mstore, "bundle-1", "global-1").unwrap();
1047
1048 let deleted = mstore.managed_delete::<Skill>(&catalog, "global-1").unwrap();
1049 assert!(deleted);
1050 assert!(catalog.managed_get::<Skill>("global-1").unwrap().is_none());
1051 assert!(!mstore.skill_is_bound_to("agent-1", "global-1").unwrap());
1052 assert!(!mstore.bundle_skill_is_bound_to("bundle-1", "global-1").unwrap());
1053 }
1054
1055 #[test]
1056 fn managed_bind_agent_succeeds_when_the_catalog_row_exists_in_the_separate_store() {
1057 let mstore = mstore();
1058 let catalog = catalog_store();
1059 insert_agent(&mstore, "agent-1");
1060 catalog.managed_upsert_unique_global(&skill("global-1", "Global", true, 100)).unwrap();
1061
1062 mstore.skill_bind(&catalog, "agent-1", "global-1").unwrap();
1063 assert!(mstore.skill_is_bound_to("agent-1", "global-1").unwrap());
1064 }
1065
1066 #[test]
1067 fn managed_bind_agent_rejects_a_catalog_row_that_does_not_exist_anywhere() {
1068 let mstore = mstore();
1069 let catalog = catalog_store();
1070 insert_agent(&mstore, "agent-1");
1071
1072 let result = mstore.skill_bind(&catalog, "agent-1", "no-such-skill");
1073 assert!(result.is_err(), "binding an id with no row in catalog must error, not silently no-op");
1074 assert!(!mstore.skill_is_bound_to("agent-1", "no-such-skill").unwrap());
1075 }
1076}