1use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use rusqlite::{OptionalExtension, params_from_iter};
21
22use crate::error::MiniAppError;
23use crate::filter::ListFilter;
24use crate::schema::SchemaConfig;
25
26#[derive(Debug, Clone, Serialize)]
36pub struct RowRecord {
37 pub id: String,
39 pub data: serde_json::Value,
41 pub created_at: i64,
43 pub updated_at: i64,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
58#[serde(rename_all = "lowercase")]
59pub enum UpdateMode {
60 #[default]
62 Merge,
63 Replace,
65}
66
67pub struct Store {
76 conn: Arc<Mutex<rusqlite::Connection>>,
77 schema: SchemaConfig,
78 db_path: PathBuf,
85}
86
87const CREATE_TABLE_SQL: &str = "
93 CREATE TABLE IF NOT EXISTS rows (
94 id TEXT PRIMARY KEY,
95 data TEXT NOT NULL,
96 created_at INTEGER NOT NULL,
97 updated_at INTEGER NOT NULL
98 )
99";
100
101const CREATE_ALIASES_TABLE_SQL: &str = "
114 CREATE TABLE IF NOT EXISTS _aliases (
115 name TEXT PRIMARY KEY,
116 filter TEXT NOT NULL,
117 default_limit INTEGER,
118 description TEXT,
119 params_schema TEXT
120 )
121";
122
123#[derive(Debug, Clone)]
129pub struct AliasRecord {
130 pub name: String,
132 pub filter: String,
135 pub default_limit: Option<u32>,
137 pub description: Option<String>,
139 pub params_schema: Option<String>,
142}
143
144fn now_secs() -> i64 {
150 SystemTime::now()
151 .duration_since(UNIX_EPOCH)
152 .unwrap_or_default()
153 .as_secs() as i64
154}
155
156fn parse_data(json_str: &str) -> Result<serde_json::Value, MiniAppError> {
158 serde_json::from_str(json_str).map_err(|e| MiniAppError::Schema(format!("data column: {e}")))
159}
160
161fn resolve_id(conn: &rusqlite::Connection, id: &str) -> Result<String, MiniAppError> {
177 if id.len() == 36 {
178 return Ok(id.to_string());
180 }
181 let mut stmt = conn.prepare("SELECT id FROM rows WHERE id LIKE ?1")?;
182 let candidates: Vec<String> = stmt
183 .query_map(rusqlite::params![format!("{}%", id)], |row| {
184 row.get::<_, String>(0)
185 })?
186 .collect::<Result<Vec<_>, _>>()?;
187 match candidates.len() {
188 0 => Err(MiniAppError::NotFound { id: id.to_string() }),
189 1 => {
190 Ok(candidates.into_iter().next().unwrap())
192 }
193 _ => Err(MiniAppError::AmbiguousId {
194 id_prefix: id.to_string(),
195 candidates,
196 }),
197 }
198}
199
200fn shallow_merge(
217 mut current: serde_json::Value,
218 patch: serde_json::Value,
219 schema: &SchemaConfig,
220) -> Result<serde_json::Value, MiniAppError> {
221 let patch_map = patch.as_object().ok_or_else(|| MiniAppError::Validation {
222 field: "(root)".to_string(),
223 reason: "patch must be a JSON object".to_string(),
224 })?;
225
226 let current_map = current
227 .as_object_mut()
228 .ok_or_else(|| MiniAppError::Validation {
229 field: "(root)".to_string(),
230 reason: "stored row is not a JSON object".to_string(),
231 })?;
232
233 for (key, value) in patch_map {
234 if value.is_null() {
235 let is_required = schema
237 .fields
238 .iter()
239 .find(|f| &f.name == key)
240 .map(|f| f.required)
241 .unwrap_or(false);
242
243 if is_required {
244 return Err(MiniAppError::Validation {
245 field: key.clone(),
246 reason: "required field cannot be deleted via null".to_string(),
247 });
248 }
249 current_map.remove(key);
250 } else {
251 current_map.insert(key.clone(), value.clone());
252 }
253 }
254
255 Ok(current)
256}
257
258impl Store {
263 pub async fn open(db_path: &Path, schema: SchemaConfig) -> Result<Self, MiniAppError> {
296 if let Some(crate::dump::SyncMode::Bidirectional) =
298 schema.dump.as_ref().and_then(|d| d.sync.as_ref())
299 {
300 tracing::warn!(
301 target: "mini_app_mcp::dump",
302 "sync=bidirectional configured but not implemented yet; behaving as write-only"
303 );
304 }
305
306 let stored_db_path = db_path.to_path_buf();
307 let db_path = db_path.to_path_buf();
308 let conn =
309 tokio::task::spawn_blocking(move || -> Result<rusqlite::Connection, MiniAppError> {
310 let c = rusqlite::Connection::open(&db_path)?;
311 c.pragma_update(None, "journal_mode", "WAL")?;
314 let actual_mode: String = c.query_row("PRAGMA journal_mode", [], |r| r.get(0))?;
318 if actual_mode.to_lowercase() != "wal" {
319 tracing::warn!(
320 actual_mode = %actual_mode,
321 "PRAGMA journal_mode=WAL fell back to non-WAL mode; \
322 concurrent reload may hit SQLITE_BUSY"
323 );
324 }
325 c.execute_batch(CREATE_TABLE_SQL)?;
326 c.execute_batch(CREATE_ALIASES_TABLE_SQL)?;
327 let has_params_schema = c
331 .prepare("PRAGMA table_info(_aliases)")?
332 .query_map([], |row| row.get::<_, String>(1))?
333 .collect::<Result<Vec<_>, _>>()?
334 .iter()
335 .any(|name| name == "params_schema");
336 if !has_params_schema {
337 c.execute_batch("ALTER TABLE _aliases ADD COLUMN params_schema TEXT")?;
338 }
339 Ok(c)
340 })
341 .await
342 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
343
344 Ok(Store {
345 conn: Arc::new(Mutex::new(conn)),
346 schema,
347 db_path: stored_db_path,
348 })
349 }
350
351 pub fn db_path(&self) -> &Path {
358 &self.db_path
359 }
360
361 pub fn conn(&self) -> Arc<Mutex<rusqlite::Connection>> {
370 Arc::clone(&self.conn)
371 }
372
373 pub async fn create(&self, value: serde_json::Value) -> Result<RowRecord, MiniAppError> {
404 self.schema.validate(&value)?;
405
406 let id = uuid::Uuid::new_v4().to_string();
407 let now = now_secs();
408 let data_str =
409 serde_json::to_string(&value).expect("serde_json::Value serialization is infallible");
410
411 let conn = self.conn.clone();
412 let id_inner = id.clone();
413 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
414 let conn = conn
415 .lock()
416 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
417 conn.execute(
418 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
419 rusqlite::params![id_inner, data_str, now, now],
420 )?;
421 Ok(RowRecord {
422 id: id_inner,
423 data: value,
424 created_at: now,
425 updated_at: now,
426 })
427 })
428 .await
429 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
430
431 crate::dump::on_change(&self.schema, &record).await?;
433
434 Ok(record)
435 }
436
437 pub async fn get(&self, id: &str) -> Result<RowRecord, MiniAppError> {
456 let conn = self.conn.clone();
457 let id = id.to_string();
458
459 tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
460 let conn = conn
461 .lock()
462 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
463 let id = resolve_id(&conn, &id)?;
464 let mut stmt =
465 conn.prepare("SELECT id, data, created_at, updated_at FROM rows WHERE id = ?1")?;
466 let row = stmt
467 .query_row(rusqlite::params![id], |row| {
468 Ok((
469 row.get::<_, String>(0)?,
470 row.get::<_, String>(1)?,
471 row.get::<_, i64>(2)?,
472 row.get::<_, i64>(3)?,
473 ))
474 })
475 .optional()?
476 .ok_or_else(|| MiniAppError::NotFound { id: id.clone() })?;
477
478 let data = parse_data(&row.1)?;
479 Ok(RowRecord {
480 id: row.0,
481 data,
482 created_at: row.2,
483 updated_at: row.3,
484 })
485 })
486 .await
487 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
488 }
489
490 pub async fn list(
511 &self,
512 limit: Option<u32>,
513 offset: Option<u32>,
514 filter: Option<ListFilter>,
515 ) -> Result<Vec<RowRecord>, MiniAppError> {
516 let conn = self.conn.clone();
517 let limit = limit.unwrap_or(100).min(1000) as i64;
518 let offset = offset.unwrap_or(0) as i64;
519
520 let (where_clause, filter_params) = match filter {
522 None => (String::new(), Vec::new()),
523 Some(f) => {
524 let (fragment, params) = f.build_sql()?;
525 (format!(" WHERE {fragment}"), params)
526 }
527 };
528
529 tokio::task::spawn_blocking(move || -> Result<Vec<RowRecord>, MiniAppError> {
530 let conn = conn
531 .lock()
532 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
533 let sql = format!(
534 "SELECT id, data, created_at, updated_at FROM rows{where_clause} \
535 ORDER BY created_at DESC LIMIT ? OFFSET ?"
536 );
537 let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = filter_params
539 .into_iter()
540 .map(|p| -> Box<dyn rusqlite::ToSql> { Box::new(p) })
541 .collect();
542 all_params.push(Box::new(limit));
543 all_params.push(Box::new(offset));
544
545 let mut stmt = conn.prepare(&sql)?;
546 let rows = stmt
547 .query_map(
548 params_from_iter(all_params.iter().map(|p| p.as_ref())),
549 |row| {
550 Ok((
551 row.get::<_, String>(0)?,
552 row.get::<_, String>(1)?,
553 row.get::<_, i64>(2)?,
554 row.get::<_, i64>(3)?,
555 ))
556 },
557 )?
558 .map(|r| {
559 r.map_err(MiniAppError::Storage).and_then(|row| {
560 let data = parse_data(&row.1)?;
561 Ok(RowRecord {
562 id: row.0,
563 data,
564 created_at: row.2,
565 updated_at: row.3,
566 })
567 })
568 })
569 .collect::<Result<Vec<_>, _>>()?;
570 Ok(rows)
571 })
572 .await
573 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
574 }
575
576 pub async fn row_count(&self) -> Result<u64, MiniAppError> {
589 let conn = self.conn.clone();
590 tokio::task::spawn_blocking(move || -> Result<u64, MiniAppError> {
591 let conn = conn
592 .lock()
593 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
594 let count: i64 = conn.query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))?;
595 Ok(count.max(0) as u64)
596 })
597 .await
598 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
599 }
600
601 pub async fn update(
645 &self,
646 id: &str,
647 value: serde_json::Value,
648 mode: UpdateMode,
649 ) -> Result<RowRecord, MiniAppError> {
650 let now = now_secs();
651 let conn = self.conn.clone();
652 let id_str = id.to_string();
653 let schema = self.schema.clone();
654
655 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
656 let conn = conn
657 .lock()
658 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
659 let id_str = resolve_id(&conn, &id_str)?;
660
661 let row_data: Option<(String, i64)> = conn
665 .query_row(
666 "SELECT data, created_at FROM rows WHERE id = ?1",
667 rusqlite::params![id_str],
668 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
669 )
670 .optional()?;
671
672 let (current_data_str, created_at) =
673 row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
674
675 let merged = match mode {
676 UpdateMode::Merge => {
677 let current: serde_json::Value = parse_data(¤t_data_str)?;
678 let merged = shallow_merge(current, value, &schema)?;
679 schema.validate(&merged)?;
681 merged
682 }
683 UpdateMode::Replace => {
684 schema.validate(&value)?;
687 value
688 }
689 };
690
691 let merged_str = serde_json::to_string(&merged)
692 .expect("serde_json::Value serialization is infallible");
693
694 conn.execute(
695 "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
696 rusqlite::params![merged_str, now, id_str],
697 )?;
698
699 Ok(RowRecord {
700 id: id_str,
701 data: merged,
702 created_at,
703 updated_at: now,
704 })
705 })
706 .await
707 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
708
709 crate::dump::on_change(&self.schema, &record).await?;
711
712 Ok(record)
713 }
714
715 pub async fn execute_under_savepoint<F, R>(&self, f: F) -> Result<R, MiniAppError>
751 where
752 F: FnOnce(&mut rusqlite::Savepoint<'_>) -> Result<R, MiniAppError> + Send + 'static,
753 R: Send + 'static,
754 {
755 let conn = self.conn.clone();
756 tokio::task::spawn_blocking(move || -> Result<R, MiniAppError> {
757 let mut guard = conn
758 .lock()
759 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
760 let mut sp = guard.savepoint()?;
761 sp.set_drop_behavior(rusqlite::DropBehavior::Rollback);
763 let result = f(&mut sp)?;
764 sp.commit()?;
765 Ok(result)
766 })
767 .await
768 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
769 }
770
771 pub async fn delete(&self, id: &str) -> Result<(), MiniAppError> {
813 let conn = self.conn.clone();
814 let id = id.to_string();
815
816 let resolved_id = tokio::task::spawn_blocking(move || -> Result<String, MiniAppError> {
819 let conn = conn
820 .lock()
821 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
822 let resolved = resolve_id(&conn, &id)?;
823 let n = conn.execute(
824 "DELETE FROM rows WHERE id = ?1",
825 rusqlite::params![resolved],
826 )?;
827 if n == 0 {
828 return Err(MiniAppError::NotFound { id: resolved });
829 }
830 Ok(resolved)
831 })
832 .await
833 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
834
835 crate::dump::on_delete(&self.schema, &resolved_id).await?;
837
838 Ok(())
839 }
840
841 pub async fn alias_create(
861 &self,
862 name: &str,
863 filter_json: &str,
864 default_limit: Option<u32>,
865 description: Option<String>,
866 params_schema: Option<String>,
867 ) -> Result<(), MiniAppError> {
868 let conn = self.conn.clone();
869 let name = name.to_string();
870 let filter_json = filter_json.to_string();
871
872 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
873 let conn = conn
874 .lock()
875 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
876 conn.execute(
877 "INSERT OR IGNORE INTO _aliases \
878 (name, filter, default_limit, description, params_schema) \
879 VALUES (?1, ?2, ?3, ?4, ?5)",
880 rusqlite::params![name, filter_json, default_limit, description, params_schema],
881 )?;
882 if conn.changes() == 0 {
883 return Err(MiniAppError::AliasAlreadyExists { name });
884 }
885 Ok(())
886 })
887 .await
888 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
889 }
890
891 pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
901 let conn = self.conn.clone();
902 let name = name.to_string();
903
904 tokio::task::spawn_blocking(move || -> Result<AliasRecord, MiniAppError> {
905 let conn = conn
906 .lock()
907 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
908 let mut stmt = conn.prepare(
909 "SELECT name, filter, default_limit, description, params_schema \
910 FROM _aliases WHERE name = ?1",
911 )?;
912 let record = stmt
913 .query_row(rusqlite::params![name], |row| {
914 Ok((
915 row.get::<_, String>(0)?,
916 row.get::<_, String>(1)?,
917 row.get::<_, Option<u32>>(2)?,
918 row.get::<_, Option<String>>(3)?,
919 row.get::<_, Option<String>>(4)?,
920 ))
921 })
922 .optional()?
923 .ok_or_else(|| MiniAppError::AliasNotFound { name: name.clone() })?;
924
925 Ok(AliasRecord {
926 name: record.0,
927 filter: record.1,
928 default_limit: record.2,
929 description: record.3,
930 params_schema: record.4,
931 })
932 })
933 .await
934 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
935 }
936
937 pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
948 let conn = self.conn.clone();
949
950 tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
951 let conn = conn
952 .lock()
953 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
954 let mut stmt = conn.prepare(
955 "SELECT name, filter, default_limit, description, params_schema \
956 FROM _aliases ORDER BY name ASC",
957 )?;
958 let records = stmt
959 .query_map([], |row| {
960 Ok((
961 row.get::<_, String>(0)?,
962 row.get::<_, String>(1)?,
963 row.get::<_, Option<u32>>(2)?,
964 row.get::<_, Option<String>>(3)?,
965 row.get::<_, Option<String>>(4)?,
966 ))
967 })?
968 .collect::<Result<Vec<_>, _>>()?;
969
970 Ok(records
971 .into_iter()
972 .map(
973 |(name, filter, default_limit, description, params_schema)| AliasRecord {
974 name,
975 filter,
976 default_limit,
977 description,
978 params_schema,
979 },
980 )
981 .collect())
982 })
983 .await
984 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
985 }
986
987 pub async fn alias_delete(&self, name: &str) -> Result<(), MiniAppError> {
997 let conn = self.conn.clone();
998 let name = name.to_string();
999
1000 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
1001 let conn = conn
1002 .lock()
1003 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1004 let n = conn.execute(
1005 "DELETE FROM _aliases WHERE name = ?1",
1006 rusqlite::params![name],
1007 )?;
1008 if n == 0 {
1009 return Err(MiniAppError::AliasNotFound { name });
1010 }
1011 Ok(())
1012 })
1013 .await
1014 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1015 }
1016}
1017
1018#[cfg(test)]
1023mod tests {
1024 use std::sync::Arc;
1025
1026 use super::*;
1027 use crate::schema::{FieldDef, FieldType};
1028
1029 async fn make_test_store() -> Store {
1030 let schema = SchemaConfig {
1031 table: "test".into(),
1032 title: None,
1033 description: None,
1034 fields: vec![
1035 FieldDef {
1036 name: "title".into(),
1037 ty: FieldType::String,
1038 required: true,
1039 description: None,
1040 },
1041 FieldDef {
1042 name: "state".into(),
1043 ty: FieldType::String,
1044 required: false,
1045 description: None,
1046 },
1047 ],
1048 dump: None,
1049 };
1050 Store::open(Path::new(":memory:"), schema).await.unwrap()
1051 }
1052
1053 async fn make_test_store_with_dump(dir: &Path) -> Store {
1055 use crate::dump::{DumpConfig, SyncMode};
1056 let schema = SchemaConfig {
1057 table: "test".into(),
1058 title: None,
1059 description: None,
1060 fields: vec![
1061 FieldDef {
1062 name: "title".into(),
1063 ty: FieldType::String,
1064 required: true,
1065 description: None,
1066 },
1067 FieldDef {
1068 name: "body".into(),
1069 ty: FieldType::String,
1070 required: false,
1071 description: None,
1072 },
1073 ],
1074 dump: Some(DumpConfig {
1075 dir: Some(dir.to_path_buf()),
1076 title_field: None,
1077 body_field: None,
1078 sync: Some(SyncMode::WriteOnly),
1079 }),
1080 };
1081 Store::open(Path::new(":memory:"), schema).await.unwrap()
1082 }
1083
1084 #[tokio::test]
1087 async fn test_create_and_get_roundtrip() {
1088 let store = make_test_store().await;
1089 let value = serde_json::json!({"title": "hello", "state": "open"});
1090 let row = store.create(value.clone()).await.unwrap();
1091 let fetched = store.get(&row.id).await.unwrap();
1092 assert_eq!(fetched.id, row.id);
1093 assert_eq!(fetched.data, value);
1094 }
1095
1096 #[tokio::test]
1097 async fn test_create_then_list() {
1098 let store = make_test_store().await;
1099 store
1100 .create(serde_json::json!({"title": "t1"}))
1101 .await
1102 .unwrap();
1103 let rows = store.list(None, None, None).await.unwrap();
1104 assert_eq!(rows.len(), 1);
1105 }
1106
1107 #[tokio::test]
1108 async fn test_list_limit_offset() {
1109 let store = make_test_store().await;
1110 for i in 0..5 {
1111 store
1112 .create(serde_json::json!({"title": format!("item-{i}")}))
1113 .await
1114 .unwrap();
1115 }
1116 let page1 = store.list(Some(2), Some(0), None).await.unwrap();
1117 assert_eq!(page1.len(), 2);
1118 let page2 = store.list(Some(2), Some(2), None).await.unwrap();
1119 assert_eq!(page2.len(), 2);
1120 let page3 = store.list(Some(2), Some(4), None).await.unwrap();
1121 assert_eq!(page3.len(), 1);
1122 }
1123
1124 #[tokio::test]
1125 async fn test_update_timestamps() {
1126 let store = make_test_store().await;
1127 let row = store
1128 .create(serde_json::json!({"title": "original"}))
1129 .await
1130 .unwrap();
1131 let updated = store
1135 .update(
1136 &row.id,
1137 serde_json::json!({"title": "changed"}),
1138 UpdateMode::Replace,
1139 )
1140 .await
1141 .unwrap();
1142 assert_eq!(updated.created_at, row.created_at);
1143 assert_eq!(updated.id, row.id);
1144 assert_eq!(updated.data["title"], "changed");
1145 }
1146
1147 #[tokio::test]
1148 async fn test_create_delete_get_not_found() {
1149 let store = make_test_store().await;
1150 let row = store
1151 .create(serde_json::json!({"title": "to-delete"}))
1152 .await
1153 .unwrap();
1154 store.delete(&row.id).await.unwrap();
1155 let err = store.get(&row.id).await.unwrap_err();
1156 assert!(matches!(err, MiniAppError::NotFound { .. }));
1157 }
1158
1159 #[tokio::test]
1160 async fn test_get_unknown_id_not_found() {
1161 let store = make_test_store().await;
1162 let err = store.get("nonexistent-id").await.unwrap_err();
1163 assert!(matches!(err, MiniAppError::NotFound { .. }));
1164 }
1165
1166 #[tokio::test]
1167 async fn test_update_unknown_id_not_found() {
1168 let store = make_test_store().await;
1169 let err = store
1170 .update(
1171 "nonexistent-id",
1172 serde_json::json!({"title": "x"}),
1173 UpdateMode::Replace,
1174 )
1175 .await
1176 .unwrap_err();
1177 assert!(matches!(err, MiniAppError::NotFound { .. }));
1178 }
1179
1180 #[tokio::test]
1181 async fn test_delete_unknown_id_not_found() {
1182 let store = make_test_store().await;
1183 let err = store.delete("nonexistent-id").await.unwrap_err();
1184 assert!(matches!(err, MiniAppError::NotFound { .. }));
1185 }
1186
1187 #[tokio::test]
1188 async fn test_create_missing_required_field_validation_error() {
1189 let store = make_test_store().await;
1190 let err = store
1192 .create(serde_json::json!({"state": "open"}))
1193 .await
1194 .unwrap_err();
1195 assert!(
1196 matches!(err, MiniAppError::Validation { .. }),
1197 "expected Validation, got: {err:?}"
1198 );
1199 }
1200
1201 #[tokio::test]
1202 async fn test_create_type_mismatch_validation_error() {
1203 let store = make_test_store().await;
1204 let err = store
1206 .create(serde_json::json!({"title": 42}))
1207 .await
1208 .unwrap_err();
1209 assert!(
1210 matches!(err, MiniAppError::Validation { .. }),
1211 "expected Validation, got: {err:?}"
1212 );
1213 }
1214
1215 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1218 async fn test_store_create_concurrent() {
1219 let store = Arc::new(make_test_store().await);
1220 let handles: Vec<_> = (0..4)
1221 .map(|i| {
1222 let s = store.clone();
1223 tokio::spawn(async move {
1224 s.create(serde_json::json!({"title": format!("task-{i}"), "state": "open"}))
1225 .await
1226 })
1227 })
1228 .collect();
1229 let results: Vec<_> = futures::future::join_all(handles).await;
1230 assert!(results.iter().all(|r| r.as_ref().unwrap().is_ok()));
1231 let rows = store.list(None, None, None).await.unwrap();
1232 assert_eq!(rows.len(), 4);
1233 }
1234
1235 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1236 async fn test_store_mutex_no_await_holding_lock() {
1237 let store = Arc::new(make_test_store().await);
1238 let id = store
1239 .create(serde_json::json!({"title": "init", "state": "open"}))
1240 .await
1241 .unwrap()
1242 .id;
1243 let s1 = store.clone();
1244 let id1 = id.clone();
1245 let h1 = tokio::spawn(async move { s1.get(&id1).await });
1246 let s2 = store.clone();
1247 let id2 = id.clone();
1248 let h2 = tokio::spawn(async move {
1249 s2.update(
1250 &id2,
1251 serde_json::json!({"title": "updated", "state": "closed"}),
1252 UpdateMode::Replace,
1253 )
1254 .await
1255 });
1256 let (r1, r2) = tokio::join!(h1, h2);
1257 assert!(r1.unwrap().is_ok());
1258 assert!(r2.unwrap().is_ok());
1259 }
1260
1261 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1262 async fn test_store_arc_clone_across_tasks() {
1263 let store = Arc::new(make_test_store().await);
1264 let handles: Vec<_> = (0..8)
1265 .map(|i| {
1266 let s = Arc::clone(&store);
1267 tokio::spawn(async move {
1268 s.create(serde_json::json!({"title": format!("row-{i}"), "state": "open"}))
1269 .await
1270 })
1271 })
1272 .collect();
1273 futures::future::join_all(handles).await;
1274 assert_eq!(store.list(None, None, None).await.unwrap().len(), 8);
1275 }
1276
1277 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1278 async fn test_spawn_blocking_join_error_propagation() {
1279 let result: Result<(), _> = tokio::task::spawn_blocking(|| panic!("intentional"))
1280 .await
1281 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")));
1282 assert!(matches!(result, Err(MiniAppError::Schema(_))));
1283 }
1284
1285 #[tokio::test]
1288 async fn create_triggers_dump_when_configured() {
1289 let tmp = tempfile::tempdir().expect("tempdir");
1290 let store = make_test_store_with_dump(tmp.path()).await;
1291 let row = store
1292 .create(serde_json::json!({"title": "My Issue", "body": "Details"}))
1293 .await
1294 .expect("create ok");
1295 let dump_file = tmp.path().join(format!("{}.md", row.id));
1296 assert!(dump_file.exists(), "dump file must be created after create");
1297 let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1298 assert!(content.starts_with("# My Issue\n"));
1299 assert!(content.contains("Details"));
1300 }
1301
1302 #[tokio::test]
1303 async fn update_overwrites_dump_file() {
1304 let tmp = tempfile::tempdir().expect("tempdir");
1305 let store = make_test_store_with_dump(tmp.path()).await;
1306 let row = store
1307 .create(serde_json::json!({"title": "Original", "body": "v1"}))
1308 .await
1309 .expect("create ok");
1310
1311 store
1312 .update(
1313 &row.id,
1314 serde_json::json!({"title": "Updated", "body": "v2"}),
1315 UpdateMode::Replace,
1316 )
1317 .await
1318 .expect("update ok");
1319
1320 let dump_file = tmp.path().join(format!("{}.md", row.id));
1321 let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1322 assert!(
1323 content.starts_with("# Updated\n"),
1324 "dump file must reflect updated title"
1325 );
1326 assert!(
1327 content.contains("v2"),
1328 "dump file must reflect updated body"
1329 );
1330 }
1331
1332 #[tokio::test]
1333 async fn delete_keeps_dump_file_by_default() {
1334 let tmp = tempfile::tempdir().expect("tempdir");
1335 let store = make_test_store_with_dump(tmp.path()).await;
1336 let row = store
1337 .create(serde_json::json!({"title": "Keep Me", "body": ""}))
1338 .await
1339 .expect("create ok");
1340
1341 let dump_file = tmp.path().join(format!("{}.md", row.id));
1342 assert!(dump_file.exists(), "dump file must exist after create");
1343
1344 store.delete(&row.id).await.expect("delete ok");
1345 assert!(
1346 dump_file.exists(),
1347 "dump file must remain after delete (default: keep)"
1348 );
1349 }
1350
1351 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1352 async fn test_store_create_concurrent_dump_writes_all_files() {
1353 let tmp = tempfile::tempdir().expect("tempdir");
1354 let store = Arc::new(make_test_store_with_dump(tmp.path()).await);
1355
1356 let handles: Vec<_> = (0..4)
1357 .map(|i| {
1358 let s = store.clone();
1359 tokio::spawn(async move {
1360 s.create(serde_json::json!({
1361 "title": format!("concurrent-{i}"),
1362 "body": format!("body-{i}"),
1363 }))
1364 .await
1365 })
1366 })
1367 .collect();
1368
1369 let results: Vec<_> = futures::future::join_all(handles).await;
1370 let rows: Vec<_> = results
1372 .into_iter()
1373 .map(|r| r.expect("spawn ok").expect("create ok"))
1374 .collect();
1375
1376 for row in &rows {
1378 let path = tmp.path().join(format!("{}.md", row.id));
1379 assert!(path.exists(), "dump file must exist for row {}", row.id);
1380 }
1381 assert_eq!(rows.len(), 4);
1382 }
1383
1384 #[tokio::test]
1385 async fn store_open_with_bidirectional_sync_returns_ok() {
1386 use crate::dump::{DumpConfig, SyncMode};
1387 let schema = SchemaConfig {
1390 table: "test".into(),
1391 title: None,
1392 description: None,
1393 fields: vec![FieldDef {
1394 name: "title".into(),
1395 ty: FieldType::String,
1396 required: false,
1397 description: None,
1398 }],
1399 dump: Some(DumpConfig {
1400 dir: None,
1401 title_field: None,
1402 body_field: None,
1403 sync: Some(SyncMode::Bidirectional),
1404 }),
1405 };
1406 let store = Store::open(Path::new(":memory:"), schema).await;
1407 assert!(
1408 store.is_ok(),
1409 "Store::open must succeed even with bidirectional sync configured"
1410 );
1411 }
1412
1413 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1421 async fn test_savepoint_atomic_rollback_on_op_failure() {
1422 let store = make_test_store().await;
1423
1424 let result: Result<(), MiniAppError> = store
1426 .execute_under_savepoint(|sp| {
1427 sp.execute(
1428 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
1429 rusqlite::params!["sp-test-id", r#"{"title":"t"}"#, 1000_i64, 1000_i64],
1430 )?;
1431 Err(MiniAppError::Validation {
1433 field: "test".into(),
1434 reason: "forced rollback".into(),
1435 })
1436 })
1437 .await;
1438
1439 assert!(
1440 result.is_err(),
1441 "execute_under_savepoint must propagate the closure error"
1442 );
1443 assert!(
1444 matches!(result.unwrap_err(), MiniAppError::Validation { .. }),
1445 "error variant must be preserved"
1446 );
1447
1448 let rows = store.list(Some(1000), None, None).await.unwrap();
1450 assert_eq!(
1451 rows.len(),
1452 0,
1453 "SAVEPOINT rollback must revert the INSERT (Crux: SAVEPOINT atomicity)"
1454 );
1455
1456 store
1458 .create(serde_json::json!({"title": "after-rollback"}))
1459 .await
1460 .expect("store must be usable after SAVEPOINT rollback");
1461 assert_eq!(store.list(None, None, None).await.unwrap().len(), 1);
1462 }
1463
1464 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1466 async fn test_savepoint_commit_on_success() {
1467 let store = make_test_store().await;
1468
1469 let result = store
1470 .execute_under_savepoint(|sp| {
1471 sp.execute(
1472 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
1473 rusqlite::params!["sp-ok-id", r#"{"title":"committed"}"#, 1000_i64, 1000_i64],
1474 )?;
1475 Ok(42_u32)
1476 })
1477 .await;
1478
1479 assert_eq!(
1480 result.unwrap(),
1481 42_u32,
1482 "successful SAVEPOINT must return value"
1483 );
1484
1485 let rows = store.list(Some(10), None, None).await.unwrap();
1487 assert_eq!(rows.len(), 1, "committed INSERT must persist");
1488 }
1489
1490 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1493 async fn test_store_concurrent_create() {
1494 let store = Arc::new(make_test_store().await);
1495 let task_count = 8_usize;
1496 let rows_per_task = 100_usize;
1497
1498 let handles: Vec<_> = (0..task_count)
1499 .map(|task_id| {
1500 let s = Arc::clone(&store);
1501 tokio::spawn(async move {
1502 for i in 0..rows_per_task {
1503 s.create(serde_json::json!({"title": format!("task-{task_id}-row-{i}")}))
1504 .await
1505 .expect("concurrent create must succeed");
1506 }
1507 })
1508 })
1509 .collect();
1510
1511 futures::future::join_all(handles)
1512 .await
1513 .into_iter()
1514 .for_each(|r| r.expect("task must not panic"));
1515
1516 let total = store.list(Some(1000), None, None).await.unwrap().len();
1517 assert_eq!(
1518 total,
1519 task_count * rows_per_task,
1520 "all {total} rows must be present; expected {}",
1521 task_count * rows_per_task
1522 );
1523 }
1524
1525 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1528 async fn test_store_concurrent_update_same_id() {
1529 let store = Arc::new(make_test_store().await);
1530
1531 let row = store
1533 .create(serde_json::json!({"title": "initial"}))
1534 .await
1535 .unwrap();
1536 let id = row.id.clone();
1537
1538 let task_count = 4_usize;
1539 let updates_per_task = 50_usize;
1540
1541 let handles: Vec<_> = (0..task_count)
1542 .map(|task_id| {
1543 let s = Arc::clone(&store);
1544 let row_id = id.clone();
1545 tokio::spawn(async move {
1546 for i in 0..updates_per_task {
1547 s.update(
1548 &row_id,
1549 serde_json::json!({"title": format!("task-{task_id}-update-{i}")}),
1550 UpdateMode::Replace,
1551 )
1552 .await
1553 .expect("concurrent update must succeed");
1554 }
1555 })
1556 })
1557 .collect();
1558
1559 futures::future::join_all(handles)
1560 .await
1561 .into_iter()
1562 .for_each(|r| r.expect("task must not panic"));
1563
1564 let rows = store.list(None, None, None).await.unwrap();
1566 assert_eq!(rows.len(), 1, "update must not insert extra rows");
1567 assert!(
1568 rows[0].data["title"].is_string(),
1569 "title must be a string after concurrent updates"
1570 );
1571 }
1572
1573 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1578 async fn test_store_mutex_poison_propagated_as_error() {
1579 let store = Arc::new(make_test_store().await);
1580
1581 let conn = store.conn.clone();
1583 let _ = tokio::task::spawn_blocking(move || {
1584 let _guard = conn.lock().unwrap(); panic!("intentional poison"); })
1587 .await; let err = store.get("any-id").await.unwrap_err();
1591 assert!(
1592 matches!(&err, MiniAppError::Schema(msg) if msg.contains("mutex poisoned")),
1593 "expected Schema(\"mutex poisoned\"), got: {err:?}"
1594 );
1595 }
1596
1597 #[tokio::test]
1601 async fn store_open_sets_wal_journal_mode() {
1602 let tmp = tempfile::tempdir().expect("tempdir");
1603 let db_path = tmp.path().join("test.db");
1604
1605 let schema = SchemaConfig {
1606 table: "test".into(),
1607 title: None,
1608 description: None,
1609 fields: vec![FieldDef {
1610 name: "title".into(),
1611 ty: FieldType::String,
1612 required: false,
1613 description: None,
1614 }],
1615 dump: None,
1616 };
1617 let store = Store::open(&db_path, schema)
1618 .await
1619 .expect("Store::open should succeed");
1620
1621 let mode = {
1623 let conn = store.conn.lock().expect("lock");
1624 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
1625 .expect("PRAGMA journal_mode query")
1626 };
1627 assert_eq!(
1628 mode.to_lowercase(),
1629 "wal",
1630 "Store::open must set journal_mode = WAL for dual-registry safety (Crux #1)"
1631 );
1632 }
1633
1634 fn make_schema(fields: Vec<FieldDef>) -> SchemaConfig {
1638 SchemaConfig {
1639 table: "test".into(),
1640 title: None,
1641 description: None,
1642 fields,
1643 dump: None,
1644 }
1645 }
1646
1647 #[test]
1649 fn shallow_merge_preserves_absent_fields() {
1650 let schema = make_schema(vec![
1651 FieldDef {
1652 name: "a".into(),
1653 ty: FieldType::Number,
1654 required: true,
1655 description: None,
1656 },
1657 FieldDef {
1658 name: "b".into(),
1659 ty: FieldType::Number,
1660 required: false,
1661 description: None,
1662 },
1663 ]);
1664 let current = serde_json::json!({"a": 1, "b": 2});
1665 let patch = serde_json::json!({"a": 9});
1666 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
1667 assert_eq!(merged["a"], 9, "patched field must be updated");
1668 assert_eq!(
1669 merged["b"], 2,
1670 "absent patch field must be preserved from current"
1671 );
1672 }
1673
1674 #[test]
1676 fn shallow_merge_deletes_null_for_optional_field() {
1677 let schema = make_schema(vec![
1678 FieldDef {
1679 name: "a".into(),
1680 ty: FieldType::Number,
1681 required: true,
1682 description: None,
1683 },
1684 FieldDef {
1685 name: "b".into(),
1686 ty: FieldType::Number,
1687 required: false,
1688 description: None,
1689 },
1690 ]);
1691 let current = serde_json::json!({"a": 1, "b": 2});
1692 let patch = serde_json::json!({"b": null});
1693 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
1694 assert_eq!(merged["a"], 1);
1695 assert!(
1696 merged.get("b").is_none(),
1697 "null-patched optional field must be physically removed (not set to null)"
1698 );
1699 }
1700
1701 #[test]
1703 fn shallow_merge_errors_on_null_for_required_field() {
1704 let schema = make_schema(vec![FieldDef {
1705 name: "title".into(),
1706 ty: FieldType::String,
1707 required: true,
1708 description: None,
1709 }]);
1710 let current = serde_json::json!({"title": "hello"});
1711 let patch = serde_json::json!({"title": null});
1712 let err = shallow_merge(current, patch, &schema).expect_err("must error");
1713 match err {
1714 MiniAppError::Validation { field, reason } => {
1715 assert_eq!(field, "title");
1716 assert!(
1717 reason.contains("required field cannot be deleted via null"),
1718 "unexpected reason: {reason}"
1719 );
1720 }
1721 other => panic!("expected Validation error, got: {other:?}"),
1722 }
1723 }
1724
1725 #[test]
1727 fn shallow_merge_replaces_nested_object_wholesale() {
1728 let schema = make_schema(vec![FieldDef {
1729 name: "cfg".into(),
1730 ty: FieldType::Object,
1731 required: false,
1732 description: None,
1733 }]);
1734 let current = serde_json::json!({"cfg": {"x": 1, "y": 2}});
1735 let patch = serde_json::json!({"cfg": {"x": 9}});
1736 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
1737 assert_eq!(merged["cfg"]["x"], 9, "x must be updated");
1738 assert!(
1739 merged["cfg"].get("y").is_none(),
1740 "y must be absent (nested object replaced wholesale, not deep-merged)"
1741 );
1742 }
1743
1744 #[test]
1746 fn shallow_merge_rejects_non_object_patch() {
1747 let schema = make_schema(vec![]);
1748 let current = serde_json::json!({"a": 1});
1749
1750 for bad_patch in [
1751 serde_json::json!([1, 2, 3]),
1752 serde_json::json!(42),
1753 serde_json::json!("string"),
1754 ] {
1755 let err = shallow_merge(current.clone(), bad_patch, &schema)
1756 .expect_err("non-object patch must be rejected");
1757 match err {
1758 MiniAppError::Validation { field, .. } => {
1759 assert_eq!(field, "(root)", "error field must be '(root)'");
1760 }
1761 other => panic!("expected Validation error, got: {other:?}"),
1762 }
1763 }
1764 }
1765
1766 #[tokio::test]
1769 async fn store_update_merge_runs_post_merge_validation() {
1770 let store = make_test_store().await;
1771 let row = store
1772 .create(serde_json::json!({"title": "x", "state": "open"}))
1773 .await
1774 .unwrap();
1775
1776 let err = store
1778 .update(&row.id, serde_json::json!({"state": 42}), UpdateMode::Merge)
1779 .await
1780 .expect_err("type mismatch must fail post-merge validation");
1781
1782 assert!(
1783 matches!(err, MiniAppError::Validation { .. }),
1784 "expected Validation error, got: {err:?}"
1785 );
1786 }
1787
1788 use crate::filter::ListFilter;
1793
1794 fn make_filter() -> ListFilter {
1796 ListFilter::Eq {
1797 field: "state".to_string(),
1798 value: serde_json::json!("open"),
1799 }
1800 }
1801
1802 #[tokio::test]
1804 async fn alias_create_and_get_round_trip() {
1805 let store = make_test_store().await;
1806 let filter = make_filter();
1807 let filter_json = serde_json::to_string(&filter).unwrap();
1808
1809 store
1810 .alias_create(
1811 "recent_open",
1812 &filter_json,
1813 Some(20),
1814 Some("desc".to_string()),
1815 None,
1816 )
1817 .await
1818 .expect("alias_create must succeed");
1819
1820 let record = store
1821 .alias_get("recent_open")
1822 .await
1823 .expect("alias_get must succeed");
1824
1825 assert_eq!(record.name, "recent_open");
1826 assert_eq!(record.default_limit, Some(20));
1827 assert_eq!(record.description.as_deref(), Some("desc"));
1828
1829 let restored: ListFilter =
1831 serde_json::from_str(&record.filter).expect("filter must deserialise");
1832 let stored_back = serde_json::to_string(&filter).unwrap();
1833 let stored_back2 = serde_json::to_string(&restored).unwrap();
1834 assert_eq!(
1835 stored_back, stored_back2,
1836 "filter must survive a JSON round-trip"
1837 );
1838 }
1839
1840 #[tokio::test]
1842 async fn alias_create_with_optional_nulls() {
1843 let store = make_test_store().await;
1844 let filter = make_filter();
1845 let filter_json = serde_json::to_string(&filter).unwrap();
1846
1847 store
1848 .alias_create("no_opts", &filter_json, None, None, None)
1849 .await
1850 .expect("alias_create must succeed with None optionals");
1851
1852 let record = store
1853 .alias_get("no_opts")
1854 .await
1855 .expect("alias_get must succeed");
1856 assert_eq!(record.name, "no_opts");
1857 assert!(record.default_limit.is_none());
1858 assert!(record.description.is_none());
1859 }
1860
1861 #[tokio::test]
1863 async fn alias_list_returns_all() {
1864 let store = make_test_store().await;
1865 let filter = make_filter();
1866
1867 let list = store
1869 .alias_list()
1870 .await
1871 .expect("alias_list must succeed on empty store");
1872 assert!(list.is_empty(), "empty store should return empty list");
1873
1874 let filter_json = serde_json::to_string(&filter).unwrap();
1875 store
1876 .alias_create("b_alias", &filter_json, None, None, None)
1877 .await
1878 .unwrap();
1879 store
1880 .alias_create("a_alias", &filter_json, None, None, None)
1881 .await
1882 .unwrap();
1883
1884 let list = store.alias_list().await.expect("alias_list must succeed");
1885 assert_eq!(list.len(), 2, "must return 2 aliases");
1886 assert_eq!(list[0].name, "a_alias");
1888 assert_eq!(list[1].name, "b_alias");
1889 }
1890
1891 #[tokio::test]
1893 async fn alias_delete_removes_alias() {
1894 let store = make_test_store().await;
1895 let filter = make_filter();
1896 let filter_json = serde_json::to_string(&filter).unwrap();
1897
1898 store
1899 .alias_create("to_delete", &filter_json, None, None, None)
1900 .await
1901 .unwrap();
1902
1903 store
1904 .alias_delete("to_delete")
1905 .await
1906 .expect("alias_delete must succeed");
1907
1908 let err = store
1909 .alias_get("to_delete")
1910 .await
1911 .expect_err("alias_get after delete must fail");
1912
1913 assert!(
1914 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "to_delete"),
1915 "expected AliasNotFound, got: {err:?}"
1916 );
1917 }
1918
1919 #[tokio::test]
1921 async fn alias_create_duplicate_returns_already_exists() {
1922 let store = make_test_store().await;
1923 let filter = make_filter();
1924 let filter_json = serde_json::to_string(&filter).unwrap();
1925
1926 store
1927 .alias_create("dup", &filter_json, None, None, None)
1928 .await
1929 .expect("first alias_create must succeed");
1930
1931 let err = store
1932 .alias_create("dup", &filter_json, None, None, None)
1933 .await
1934 .expect_err("second alias_create must fail");
1935
1936 assert!(
1937 matches!(err, MiniAppError::AliasAlreadyExists { ref name } if name == "dup"),
1938 "expected AliasAlreadyExists, got: {err:?}"
1939 );
1940 }
1941
1942 #[tokio::test]
1944 async fn alias_get_missing_returns_not_found() {
1945 let store = make_test_store().await;
1946
1947 let err = store
1948 .alias_get("nonexistent")
1949 .await
1950 .expect_err("alias_get on missing alias must fail");
1951
1952 assert!(
1953 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
1954 "expected AliasNotFound, got: {err:?}"
1955 );
1956 }
1957
1958 #[tokio::test]
1960 async fn alias_delete_missing_returns_not_found() {
1961 let store = make_test_store().await;
1962
1963 let err = store
1964 .alias_delete("nonexistent")
1965 .await
1966 .expect_err("alias_delete on missing alias must fail");
1967
1968 assert!(
1969 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
1970 "expected AliasNotFound, got: {err:?}"
1971 );
1972 }
1973
1974 #[tokio::test]
1977 async fn alias_namespace_isolation_between_stores() {
1978 let store_a = make_test_store().await;
1979 let store_b = make_test_store().await;
1980 let filter = make_filter();
1981
1982 let filter_json = serde_json::to_string(&filter).unwrap();
1983 store_a
1984 .alias_create("shared_name", &filter_json, None, None, None)
1985 .await
1986 .expect("store_a alias_create must succeed");
1987
1988 let err = store_b
1990 .alias_get("shared_name")
1991 .await
1992 .expect_err("alias created in store_a must not be visible in store_b");
1993
1994 assert!(
1995 matches!(err, MiniAppError::AliasNotFound { .. }),
1996 "expected AliasNotFound in store_b, got: {err:?}"
1997 );
1998 }
1999
2000 #[tokio::test]
2004 async fn test_get_prefix_match_single() {
2005 let store = make_test_store().await;
2006 let row = store
2007 .create(serde_json::json!({"title": "prefix-test"}))
2008 .await
2009 .unwrap();
2010 let prefix = &row.id[..8];
2012 let fetched = store.get(prefix).await.unwrap();
2013 assert_eq!(fetched.id, row.id);
2014 assert_eq!(fetched.data["title"], "prefix-test");
2015 }
2016
2017 #[tokio::test]
2019 async fn test_get_prefix_match_not_found() {
2020 let store = make_test_store().await;
2021 let err = store.get("zzzzzzzz").await.unwrap_err();
2023 assert!(
2024 matches!(err, MiniAppError::NotFound { .. }),
2025 "expected NotFound, got: {err:?}"
2026 );
2027 }
2028
2029 #[tokio::test]
2031 async fn test_get_prefix_match_ambiguous() {
2032 let store = make_test_store().await;
2033 let id1 = "aaaaaaaa-0000-4000-8000-000000000001".to_string();
2036 let id2 = "aaaaaaaa-0000-4000-8000-000000000002".to_string();
2037 let id1_clone = id1.clone();
2038 let id2_clone = id2.clone();
2039 store
2040 .execute_under_savepoint(move |sp| {
2041 sp.execute(
2042 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2043 rusqlite::params![id1_clone, r#"{"title":"a1"}"#],
2044 )?;
2045 sp.execute(
2046 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2047 rusqlite::params![id2_clone, r#"{"title":"a2"}"#],
2048 )?;
2049 Ok(())
2050 })
2051 .await
2052 .unwrap();
2053
2054 let err = store.get("aaaaaaaa").await.unwrap_err();
2055 match err {
2056 MiniAppError::AmbiguousId {
2057 ref id_prefix,
2058 ref candidates,
2059 } => {
2060 assert_eq!(id_prefix, "aaaaaaaa");
2061 assert_eq!(candidates.len(), 2);
2062 let mut sorted = candidates.clone();
2063 sorted.sort();
2064 assert_eq!(sorted[0], id1);
2065 assert_eq!(sorted[1], id2);
2066 }
2067 other => panic!("expected AmbiguousId, got: {other:?}"),
2068 }
2069 }
2070
2071 #[tokio::test]
2073 async fn test_get_full_uuid_bypass() {
2074 let store = make_test_store().await;
2075 let row = store
2076 .create(serde_json::json!({"title": "bypass-test"}))
2077 .await
2078 .unwrap();
2079 assert_eq!(row.id.len(), 36, "UUID must be 36 chars");
2080 let fetched = store.get(&row.id).await.unwrap();
2082 assert_eq!(fetched.id, row.id);
2083 }
2084
2085 #[tokio::test]
2087 async fn test_update_prefix_match_single() {
2088 let store = make_test_store().await;
2089 let row = store
2090 .create(serde_json::json!({"title": "before"}))
2091 .await
2092 .unwrap();
2093 let prefix = &row.id[..8];
2094 let updated = store
2095 .update(
2096 prefix,
2097 serde_json::json!({"title": "after"}),
2098 UpdateMode::Replace,
2099 )
2100 .await
2101 .unwrap();
2102 assert_eq!(updated.id, row.id);
2103 assert_eq!(updated.data["title"], "after");
2104 }
2105
2106 #[tokio::test]
2108 async fn test_update_prefix_match_ambiguous() {
2109 let store = make_test_store().await;
2110 let id1 = "bbbbbbbb-0000-4000-8000-000000000001".to_string();
2111 let id2 = "bbbbbbbb-0000-4000-8000-000000000002".to_string();
2112 store
2113 .execute_under_savepoint(move |sp| {
2114 sp.execute(
2115 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2116 rusqlite::params![id1, r#"{"title":"b1"}"#],
2117 )?;
2118 sp.execute(
2119 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2120 rusqlite::params![id2, r#"{"title":"b2"}"#],
2121 )?;
2122 Ok(())
2123 })
2124 .await
2125 .unwrap();
2126
2127 let err = store
2128 .update(
2129 "bbbbbbbb",
2130 serde_json::json!({"title": "x"}),
2131 UpdateMode::Replace,
2132 )
2133 .await
2134 .unwrap_err();
2135 assert!(
2136 matches!(err, MiniAppError::AmbiguousId { .. }),
2137 "expected AmbiguousId, got: {err:?}"
2138 );
2139 }
2140
2141 #[tokio::test]
2143 async fn test_delete_prefix_match_single() {
2144 let store = make_test_store().await;
2145 let row = store
2146 .create(serde_json::json!({"title": "to-delete-prefix"}))
2147 .await
2148 .unwrap();
2149 let prefix = &row.id[..8];
2150 store.delete(prefix).await.unwrap();
2151 let err = store.get(&row.id).await.unwrap_err();
2153 assert!(
2154 matches!(err, MiniAppError::NotFound { .. }),
2155 "expected NotFound after delete, got: {err:?}"
2156 );
2157 }
2158
2159 #[tokio::test]
2161 async fn test_delete_prefix_match_ambiguous() {
2162 let store = make_test_store().await;
2163 let id1 = "cccccccc-0000-4000-8000-000000000001".to_string();
2164 let id2 = "cccccccc-0000-4000-8000-000000000002".to_string();
2165 store
2166 .execute_under_savepoint(move |sp| {
2167 sp.execute(
2168 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2169 rusqlite::params![id1, r#"{"title":"c1"}"#],
2170 )?;
2171 sp.execute(
2172 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2173 rusqlite::params![id2, r#"{"title":"c2"}"#],
2174 )?;
2175 Ok(())
2176 })
2177 .await
2178 .unwrap();
2179
2180 let err = store.delete("cccccccc").await.unwrap_err();
2181 assert!(
2182 matches!(err, MiniAppError::AmbiguousId { .. }),
2183 "expected AmbiguousId, got: {err:?}"
2184 );
2185 }
2186}