1use std::path::Path;
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}
79
80const CREATE_TABLE_SQL: &str = "
86 CREATE TABLE IF NOT EXISTS rows (
87 id TEXT PRIMARY KEY,
88 data TEXT NOT NULL,
89 created_at INTEGER NOT NULL,
90 updated_at INTEGER NOT NULL
91 )
92";
93
94const CREATE_ALIASES_TABLE_SQL: &str = "
107 CREATE TABLE IF NOT EXISTS _aliases (
108 name TEXT PRIMARY KEY,
109 filter TEXT NOT NULL,
110 default_limit INTEGER,
111 description TEXT,
112 params_schema TEXT
113 )
114";
115
116#[derive(Debug, Clone)]
122pub struct AliasRecord {
123 pub name: String,
125 pub filter: String,
128 pub default_limit: Option<u32>,
130 pub description: Option<String>,
132 pub params_schema: Option<String>,
135}
136
137fn now_secs() -> i64 {
143 SystemTime::now()
144 .duration_since(UNIX_EPOCH)
145 .unwrap_or_default()
146 .as_secs() as i64
147}
148
149fn parse_data(json_str: &str) -> Result<serde_json::Value, MiniAppError> {
151 serde_json::from_str(json_str).map_err(|e| MiniAppError::Schema(format!("data column: {e}")))
152}
153
154fn resolve_id(conn: &rusqlite::Connection, id: &str) -> Result<String, MiniAppError> {
170 if id.len() == 36 {
171 return Ok(id.to_string());
173 }
174 let mut stmt = conn.prepare("SELECT id FROM rows WHERE id LIKE ?1")?;
175 let candidates: Vec<String> = stmt
176 .query_map(rusqlite::params![format!("{}%", id)], |row| {
177 row.get::<_, String>(0)
178 })?
179 .collect::<Result<Vec<_>, _>>()?;
180 match candidates.len() {
181 0 => Err(MiniAppError::NotFound { id: id.to_string() }),
182 1 => {
183 Ok(candidates.into_iter().next().unwrap())
185 }
186 _ => Err(MiniAppError::AmbiguousId {
187 id_prefix: id.to_string(),
188 candidates,
189 }),
190 }
191}
192
193fn shallow_merge(
210 mut current: serde_json::Value,
211 patch: serde_json::Value,
212 schema: &SchemaConfig,
213) -> Result<serde_json::Value, MiniAppError> {
214 let patch_map = patch.as_object().ok_or_else(|| MiniAppError::Validation {
215 field: "(root)".to_string(),
216 reason: "patch must be a JSON object".to_string(),
217 })?;
218
219 let current_map = current
220 .as_object_mut()
221 .ok_or_else(|| MiniAppError::Validation {
222 field: "(root)".to_string(),
223 reason: "stored row is not a JSON object".to_string(),
224 })?;
225
226 for (key, value) in patch_map {
227 if value.is_null() {
228 let is_required = schema
230 .fields
231 .iter()
232 .find(|f| &f.name == key)
233 .map(|f| f.required)
234 .unwrap_or(false);
235
236 if is_required {
237 return Err(MiniAppError::Validation {
238 field: key.clone(),
239 reason: "required field cannot be deleted via null".to_string(),
240 });
241 }
242 current_map.remove(key);
243 } else {
244 current_map.insert(key.clone(), value.clone());
245 }
246 }
247
248 Ok(current)
249}
250
251impl Store {
256 pub async fn open(db_path: &Path, schema: SchemaConfig) -> Result<Self, MiniAppError> {
289 if let Some(crate::dump::SyncMode::Bidirectional) =
291 schema.dump.as_ref().and_then(|d| d.sync.as_ref())
292 {
293 tracing::warn!(
294 target: "mini_app_mcp::dump",
295 "sync=bidirectional configured but not implemented yet; behaving as write-only"
296 );
297 }
298
299 let db_path = db_path.to_path_buf();
300 let conn =
301 tokio::task::spawn_blocking(move || -> Result<rusqlite::Connection, MiniAppError> {
302 let c = rusqlite::Connection::open(&db_path)?;
303 c.pragma_update(None, "journal_mode", "WAL")?;
306 let actual_mode: String = c.query_row("PRAGMA journal_mode", [], |r| r.get(0))?;
310 if actual_mode.to_lowercase() != "wal" {
311 tracing::warn!(
312 actual_mode = %actual_mode,
313 "PRAGMA journal_mode=WAL fell back to non-WAL mode; \
314 concurrent reload may hit SQLITE_BUSY"
315 );
316 }
317 c.execute_batch(CREATE_TABLE_SQL)?;
318 c.execute_batch(CREATE_ALIASES_TABLE_SQL)?;
319 let has_params_schema = c
323 .prepare("PRAGMA table_info(_aliases)")?
324 .query_map([], |row| row.get::<_, String>(1))?
325 .collect::<Result<Vec<_>, _>>()?
326 .iter()
327 .any(|name| name == "params_schema");
328 if !has_params_schema {
329 c.execute_batch("ALTER TABLE _aliases ADD COLUMN params_schema TEXT")?;
330 }
331 Ok(c)
332 })
333 .await
334 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
335
336 Ok(Store {
337 conn: Arc::new(Mutex::new(conn)),
338 schema,
339 })
340 }
341
342 pub async fn create(&self, value: serde_json::Value) -> Result<RowRecord, MiniAppError> {
373 self.schema.validate(&value)?;
374
375 let id = uuid::Uuid::new_v4().to_string();
376 let now = now_secs();
377 let data_str =
378 serde_json::to_string(&value).expect("serde_json::Value serialization is infallible");
379
380 let conn = self.conn.clone();
381 let id_inner = id.clone();
382 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
383 let conn = conn
384 .lock()
385 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
386 conn.execute(
387 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
388 rusqlite::params![id_inner, data_str, now, now],
389 )?;
390 Ok(RowRecord {
391 id: id_inner,
392 data: value,
393 created_at: now,
394 updated_at: now,
395 })
396 })
397 .await
398 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
399
400 crate::dump::on_change(&self.schema, &record).await?;
402
403 Ok(record)
404 }
405
406 pub async fn get(&self, id: &str) -> Result<RowRecord, MiniAppError> {
425 let conn = self.conn.clone();
426 let id = id.to_string();
427
428 tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
429 let conn = conn
430 .lock()
431 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
432 let id = resolve_id(&conn, &id)?;
433 let mut stmt =
434 conn.prepare("SELECT id, data, created_at, updated_at FROM rows WHERE id = ?1")?;
435 let row = stmt
436 .query_row(rusqlite::params![id], |row| {
437 Ok((
438 row.get::<_, String>(0)?,
439 row.get::<_, String>(1)?,
440 row.get::<_, i64>(2)?,
441 row.get::<_, i64>(3)?,
442 ))
443 })
444 .optional()?
445 .ok_or_else(|| MiniAppError::NotFound { id: id.clone() })?;
446
447 let data = parse_data(&row.1)?;
448 Ok(RowRecord {
449 id: row.0,
450 data,
451 created_at: row.2,
452 updated_at: row.3,
453 })
454 })
455 .await
456 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
457 }
458
459 pub async fn list(
480 &self,
481 limit: Option<u32>,
482 offset: Option<u32>,
483 filter: Option<ListFilter>,
484 ) -> Result<Vec<RowRecord>, MiniAppError> {
485 let conn = self.conn.clone();
486 let limit = limit.unwrap_or(100).min(1000) as i64;
487 let offset = offset.unwrap_or(0) as i64;
488
489 let (where_clause, filter_params) = match filter {
491 None => (String::new(), Vec::new()),
492 Some(f) => {
493 let (fragment, params) = f.build_sql()?;
494 (format!(" WHERE {fragment}"), params)
495 }
496 };
497
498 tokio::task::spawn_blocking(move || -> Result<Vec<RowRecord>, MiniAppError> {
499 let conn = conn
500 .lock()
501 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
502 let sql = format!(
503 "SELECT id, data, created_at, updated_at FROM rows{where_clause} \
504 ORDER BY created_at DESC LIMIT ? OFFSET ?"
505 );
506 let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = filter_params
508 .into_iter()
509 .map(|p| -> Box<dyn rusqlite::ToSql> { Box::new(p) })
510 .collect();
511 all_params.push(Box::new(limit));
512 all_params.push(Box::new(offset));
513
514 let mut stmt = conn.prepare(&sql)?;
515 let rows = stmt
516 .query_map(
517 params_from_iter(all_params.iter().map(|p| p.as_ref())),
518 |row| {
519 Ok((
520 row.get::<_, String>(0)?,
521 row.get::<_, String>(1)?,
522 row.get::<_, i64>(2)?,
523 row.get::<_, i64>(3)?,
524 ))
525 },
526 )?
527 .map(|r| {
528 r.map_err(MiniAppError::Storage).and_then(|row| {
529 let data = parse_data(&row.1)?;
530 Ok(RowRecord {
531 id: row.0,
532 data,
533 created_at: row.2,
534 updated_at: row.3,
535 })
536 })
537 })
538 .collect::<Result<Vec<_>, _>>()?;
539 Ok(rows)
540 })
541 .await
542 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
543 }
544
545 pub async fn row_count(&self) -> Result<u64, MiniAppError> {
558 let conn = self.conn.clone();
559 tokio::task::spawn_blocking(move || -> Result<u64, MiniAppError> {
560 let conn = conn
561 .lock()
562 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
563 let count: i64 = conn.query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))?;
564 Ok(count.max(0) as u64)
565 })
566 .await
567 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
568 }
569
570 pub async fn update(
614 &self,
615 id: &str,
616 value: serde_json::Value,
617 mode: UpdateMode,
618 ) -> Result<RowRecord, MiniAppError> {
619 let now = now_secs();
620 let conn = self.conn.clone();
621 let id_str = id.to_string();
622 let schema = self.schema.clone();
623
624 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
625 let conn = conn
626 .lock()
627 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
628 let id_str = resolve_id(&conn, &id_str)?;
629
630 let row_data: Option<(String, i64)> = conn
634 .query_row(
635 "SELECT data, created_at FROM rows WHERE id = ?1",
636 rusqlite::params![id_str],
637 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
638 )
639 .optional()?;
640
641 let (current_data_str, created_at) =
642 row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
643
644 let merged = match mode {
645 UpdateMode::Merge => {
646 let current: serde_json::Value = parse_data(¤t_data_str)?;
647 let merged = shallow_merge(current, value, &schema)?;
648 schema.validate(&merged)?;
650 merged
651 }
652 UpdateMode::Replace => {
653 schema.validate(&value)?;
656 value
657 }
658 };
659
660 let merged_str = serde_json::to_string(&merged)
661 .expect("serde_json::Value serialization is infallible");
662
663 conn.execute(
664 "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
665 rusqlite::params![merged_str, now, id_str],
666 )?;
667
668 Ok(RowRecord {
669 id: id_str,
670 data: merged,
671 created_at,
672 updated_at: now,
673 })
674 })
675 .await
676 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
677
678 crate::dump::on_change(&self.schema, &record).await?;
680
681 Ok(record)
682 }
683
684 pub async fn execute_under_savepoint<F, R>(&self, f: F) -> Result<R, MiniAppError>
720 where
721 F: FnOnce(&mut rusqlite::Savepoint<'_>) -> Result<R, MiniAppError> + Send + 'static,
722 R: Send + 'static,
723 {
724 let conn = self.conn.clone();
725 tokio::task::spawn_blocking(move || -> Result<R, MiniAppError> {
726 let mut guard = conn
727 .lock()
728 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
729 let mut sp = guard.savepoint()?;
730 sp.set_drop_behavior(rusqlite::DropBehavior::Rollback);
732 let result = f(&mut sp)?;
733 sp.commit()?;
734 Ok(result)
735 })
736 .await
737 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
738 }
739
740 pub async fn delete(&self, id: &str) -> Result<(), MiniAppError> {
782 let conn = self.conn.clone();
783 let id = id.to_string();
784
785 let resolved_id = tokio::task::spawn_blocking(move || -> Result<String, MiniAppError> {
788 let conn = conn
789 .lock()
790 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
791 let resolved = resolve_id(&conn, &id)?;
792 let n = conn.execute(
793 "DELETE FROM rows WHERE id = ?1",
794 rusqlite::params![resolved],
795 )?;
796 if n == 0 {
797 return Err(MiniAppError::NotFound { id: resolved });
798 }
799 Ok(resolved)
800 })
801 .await
802 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
803
804 crate::dump::on_delete(&self.schema, &resolved_id).await?;
806
807 Ok(())
808 }
809
810 pub async fn alias_create(
830 &self,
831 name: &str,
832 filter_json: &str,
833 default_limit: Option<u32>,
834 description: Option<String>,
835 params_schema: Option<String>,
836 ) -> Result<(), MiniAppError> {
837 let conn = self.conn.clone();
838 let name = name.to_string();
839 let filter_json = filter_json.to_string();
840
841 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
842 let conn = conn
843 .lock()
844 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
845 conn.execute(
846 "INSERT OR IGNORE INTO _aliases \
847 (name, filter, default_limit, description, params_schema) \
848 VALUES (?1, ?2, ?3, ?4, ?5)",
849 rusqlite::params![name, filter_json, default_limit, description, params_schema],
850 )?;
851 if conn.changes() == 0 {
852 return Err(MiniAppError::AliasAlreadyExists { name });
853 }
854 Ok(())
855 })
856 .await
857 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
858 }
859
860 pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
870 let conn = self.conn.clone();
871 let name = name.to_string();
872
873 tokio::task::spawn_blocking(move || -> Result<AliasRecord, MiniAppError> {
874 let conn = conn
875 .lock()
876 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
877 let mut stmt = conn.prepare(
878 "SELECT name, filter, default_limit, description, params_schema \
879 FROM _aliases WHERE name = ?1",
880 )?;
881 let record = stmt
882 .query_row(rusqlite::params![name], |row| {
883 Ok((
884 row.get::<_, String>(0)?,
885 row.get::<_, String>(1)?,
886 row.get::<_, Option<u32>>(2)?,
887 row.get::<_, Option<String>>(3)?,
888 row.get::<_, Option<String>>(4)?,
889 ))
890 })
891 .optional()?
892 .ok_or_else(|| MiniAppError::AliasNotFound { name: name.clone() })?;
893
894 Ok(AliasRecord {
895 name: record.0,
896 filter: record.1,
897 default_limit: record.2,
898 description: record.3,
899 params_schema: record.4,
900 })
901 })
902 .await
903 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
904 }
905
906 pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
917 let conn = self.conn.clone();
918
919 tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
920 let conn = conn
921 .lock()
922 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
923 let mut stmt = conn.prepare(
924 "SELECT name, filter, default_limit, description, params_schema \
925 FROM _aliases ORDER BY name ASC",
926 )?;
927 let records = stmt
928 .query_map([], |row| {
929 Ok((
930 row.get::<_, String>(0)?,
931 row.get::<_, String>(1)?,
932 row.get::<_, Option<u32>>(2)?,
933 row.get::<_, Option<String>>(3)?,
934 row.get::<_, Option<String>>(4)?,
935 ))
936 })?
937 .collect::<Result<Vec<_>, _>>()?;
938
939 Ok(records
940 .into_iter()
941 .map(
942 |(name, filter, default_limit, description, params_schema)| AliasRecord {
943 name,
944 filter,
945 default_limit,
946 description,
947 params_schema,
948 },
949 )
950 .collect())
951 })
952 .await
953 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
954 }
955
956 pub async fn alias_delete(&self, name: &str) -> Result<(), MiniAppError> {
966 let conn = self.conn.clone();
967 let name = name.to_string();
968
969 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
970 let conn = conn
971 .lock()
972 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
973 let n = conn.execute(
974 "DELETE FROM _aliases WHERE name = ?1",
975 rusqlite::params![name],
976 )?;
977 if n == 0 {
978 return Err(MiniAppError::AliasNotFound { name });
979 }
980 Ok(())
981 })
982 .await
983 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
984 }
985}
986
987#[cfg(test)]
992mod tests {
993 use std::sync::Arc;
994
995 use super::*;
996 use crate::schema::{FieldDef, FieldType};
997
998 async fn make_test_store() -> Store {
999 let schema = SchemaConfig {
1000 table: "test".into(),
1001 title: None,
1002 description: None,
1003 fields: vec![
1004 FieldDef {
1005 name: "title".into(),
1006 ty: FieldType::String,
1007 required: true,
1008 description: None,
1009 },
1010 FieldDef {
1011 name: "state".into(),
1012 ty: FieldType::String,
1013 required: false,
1014 description: None,
1015 },
1016 ],
1017 dump: None,
1018 };
1019 Store::open(Path::new(":memory:"), schema).await.unwrap()
1020 }
1021
1022 async fn make_test_store_with_dump(dir: &Path) -> Store {
1024 use crate::dump::{DumpConfig, SyncMode};
1025 let schema = SchemaConfig {
1026 table: "test".into(),
1027 title: None,
1028 description: None,
1029 fields: vec![
1030 FieldDef {
1031 name: "title".into(),
1032 ty: FieldType::String,
1033 required: true,
1034 description: None,
1035 },
1036 FieldDef {
1037 name: "body".into(),
1038 ty: FieldType::String,
1039 required: false,
1040 description: None,
1041 },
1042 ],
1043 dump: Some(DumpConfig {
1044 dir: Some(dir.to_path_buf()),
1045 title_field: None,
1046 body_field: None,
1047 sync: Some(SyncMode::WriteOnly),
1048 }),
1049 };
1050 Store::open(Path::new(":memory:"), schema).await.unwrap()
1051 }
1052
1053 #[tokio::test]
1056 async fn test_create_and_get_roundtrip() {
1057 let store = make_test_store().await;
1058 let value = serde_json::json!({"title": "hello", "state": "open"});
1059 let row = store.create(value.clone()).await.unwrap();
1060 let fetched = store.get(&row.id).await.unwrap();
1061 assert_eq!(fetched.id, row.id);
1062 assert_eq!(fetched.data, value);
1063 }
1064
1065 #[tokio::test]
1066 async fn test_create_then_list() {
1067 let store = make_test_store().await;
1068 store
1069 .create(serde_json::json!({"title": "t1"}))
1070 .await
1071 .unwrap();
1072 let rows = store.list(None, None, None).await.unwrap();
1073 assert_eq!(rows.len(), 1);
1074 }
1075
1076 #[tokio::test]
1077 async fn test_list_limit_offset() {
1078 let store = make_test_store().await;
1079 for i in 0..5 {
1080 store
1081 .create(serde_json::json!({"title": format!("item-{i}")}))
1082 .await
1083 .unwrap();
1084 }
1085 let page1 = store.list(Some(2), Some(0), None).await.unwrap();
1086 assert_eq!(page1.len(), 2);
1087 let page2 = store.list(Some(2), Some(2), None).await.unwrap();
1088 assert_eq!(page2.len(), 2);
1089 let page3 = store.list(Some(2), Some(4), None).await.unwrap();
1090 assert_eq!(page3.len(), 1);
1091 }
1092
1093 #[tokio::test]
1094 async fn test_update_timestamps() {
1095 let store = make_test_store().await;
1096 let row = store
1097 .create(serde_json::json!({"title": "original"}))
1098 .await
1099 .unwrap();
1100 let updated = store
1104 .update(
1105 &row.id,
1106 serde_json::json!({"title": "changed"}),
1107 UpdateMode::Replace,
1108 )
1109 .await
1110 .unwrap();
1111 assert_eq!(updated.created_at, row.created_at);
1112 assert_eq!(updated.id, row.id);
1113 assert_eq!(updated.data["title"], "changed");
1114 }
1115
1116 #[tokio::test]
1117 async fn test_create_delete_get_not_found() {
1118 let store = make_test_store().await;
1119 let row = store
1120 .create(serde_json::json!({"title": "to-delete"}))
1121 .await
1122 .unwrap();
1123 store.delete(&row.id).await.unwrap();
1124 let err = store.get(&row.id).await.unwrap_err();
1125 assert!(matches!(err, MiniAppError::NotFound { .. }));
1126 }
1127
1128 #[tokio::test]
1129 async fn test_get_unknown_id_not_found() {
1130 let store = make_test_store().await;
1131 let err = store.get("nonexistent-id").await.unwrap_err();
1132 assert!(matches!(err, MiniAppError::NotFound { .. }));
1133 }
1134
1135 #[tokio::test]
1136 async fn test_update_unknown_id_not_found() {
1137 let store = make_test_store().await;
1138 let err = store
1139 .update(
1140 "nonexistent-id",
1141 serde_json::json!({"title": "x"}),
1142 UpdateMode::Replace,
1143 )
1144 .await
1145 .unwrap_err();
1146 assert!(matches!(err, MiniAppError::NotFound { .. }));
1147 }
1148
1149 #[tokio::test]
1150 async fn test_delete_unknown_id_not_found() {
1151 let store = make_test_store().await;
1152 let err = store.delete("nonexistent-id").await.unwrap_err();
1153 assert!(matches!(err, MiniAppError::NotFound { .. }));
1154 }
1155
1156 #[tokio::test]
1157 async fn test_create_missing_required_field_validation_error() {
1158 let store = make_test_store().await;
1159 let err = store
1161 .create(serde_json::json!({"state": "open"}))
1162 .await
1163 .unwrap_err();
1164 assert!(
1165 matches!(err, MiniAppError::Validation { .. }),
1166 "expected Validation, got: {err:?}"
1167 );
1168 }
1169
1170 #[tokio::test]
1171 async fn test_create_type_mismatch_validation_error() {
1172 let store = make_test_store().await;
1173 let err = store
1175 .create(serde_json::json!({"title": 42}))
1176 .await
1177 .unwrap_err();
1178 assert!(
1179 matches!(err, MiniAppError::Validation { .. }),
1180 "expected Validation, got: {err:?}"
1181 );
1182 }
1183
1184 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1187 async fn test_store_create_concurrent() {
1188 let store = Arc::new(make_test_store().await);
1189 let handles: Vec<_> = (0..4)
1190 .map(|i| {
1191 let s = store.clone();
1192 tokio::spawn(async move {
1193 s.create(serde_json::json!({"title": format!("task-{i}"), "state": "open"}))
1194 .await
1195 })
1196 })
1197 .collect();
1198 let results: Vec<_> = futures::future::join_all(handles).await;
1199 assert!(results.iter().all(|r| r.as_ref().unwrap().is_ok()));
1200 let rows = store.list(None, None, None).await.unwrap();
1201 assert_eq!(rows.len(), 4);
1202 }
1203
1204 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1205 async fn test_store_mutex_no_await_holding_lock() {
1206 let store = Arc::new(make_test_store().await);
1207 let id = store
1208 .create(serde_json::json!({"title": "init", "state": "open"}))
1209 .await
1210 .unwrap()
1211 .id;
1212 let s1 = store.clone();
1213 let id1 = id.clone();
1214 let h1 = tokio::spawn(async move { s1.get(&id1).await });
1215 let s2 = store.clone();
1216 let id2 = id.clone();
1217 let h2 = tokio::spawn(async move {
1218 s2.update(
1219 &id2,
1220 serde_json::json!({"title": "updated", "state": "closed"}),
1221 UpdateMode::Replace,
1222 )
1223 .await
1224 });
1225 let (r1, r2) = tokio::join!(h1, h2);
1226 assert!(r1.unwrap().is_ok());
1227 assert!(r2.unwrap().is_ok());
1228 }
1229
1230 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1231 async fn test_store_arc_clone_across_tasks() {
1232 let store = Arc::new(make_test_store().await);
1233 let handles: Vec<_> = (0..8)
1234 .map(|i| {
1235 let s = Arc::clone(&store);
1236 tokio::spawn(async move {
1237 s.create(serde_json::json!({"title": format!("row-{i}"), "state": "open"}))
1238 .await
1239 })
1240 })
1241 .collect();
1242 futures::future::join_all(handles).await;
1243 assert_eq!(store.list(None, None, None).await.unwrap().len(), 8);
1244 }
1245
1246 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1247 async fn test_spawn_blocking_join_error_propagation() {
1248 let result: Result<(), _> = tokio::task::spawn_blocking(|| panic!("intentional"))
1249 .await
1250 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")));
1251 assert!(matches!(result, Err(MiniAppError::Schema(_))));
1252 }
1253
1254 #[tokio::test]
1257 async fn create_triggers_dump_when_configured() {
1258 let tmp = tempfile::tempdir().expect("tempdir");
1259 let store = make_test_store_with_dump(tmp.path()).await;
1260 let row = store
1261 .create(serde_json::json!({"title": "My Issue", "body": "Details"}))
1262 .await
1263 .expect("create ok");
1264 let dump_file = tmp.path().join(format!("{}.md", row.id));
1265 assert!(dump_file.exists(), "dump file must be created after create");
1266 let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1267 assert!(content.starts_with("# My Issue\n"));
1268 assert!(content.contains("Details"));
1269 }
1270
1271 #[tokio::test]
1272 async fn update_overwrites_dump_file() {
1273 let tmp = tempfile::tempdir().expect("tempdir");
1274 let store = make_test_store_with_dump(tmp.path()).await;
1275 let row = store
1276 .create(serde_json::json!({"title": "Original", "body": "v1"}))
1277 .await
1278 .expect("create ok");
1279
1280 store
1281 .update(
1282 &row.id,
1283 serde_json::json!({"title": "Updated", "body": "v2"}),
1284 UpdateMode::Replace,
1285 )
1286 .await
1287 .expect("update ok");
1288
1289 let dump_file = tmp.path().join(format!("{}.md", row.id));
1290 let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1291 assert!(
1292 content.starts_with("# Updated\n"),
1293 "dump file must reflect updated title"
1294 );
1295 assert!(
1296 content.contains("v2"),
1297 "dump file must reflect updated body"
1298 );
1299 }
1300
1301 #[tokio::test]
1302 async fn delete_keeps_dump_file_by_default() {
1303 let tmp = tempfile::tempdir().expect("tempdir");
1304 let store = make_test_store_with_dump(tmp.path()).await;
1305 let row = store
1306 .create(serde_json::json!({"title": "Keep Me", "body": ""}))
1307 .await
1308 .expect("create ok");
1309
1310 let dump_file = tmp.path().join(format!("{}.md", row.id));
1311 assert!(dump_file.exists(), "dump file must exist after create");
1312
1313 store.delete(&row.id).await.expect("delete ok");
1314 assert!(
1315 dump_file.exists(),
1316 "dump file must remain after delete (default: keep)"
1317 );
1318 }
1319
1320 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1321 async fn test_store_create_concurrent_dump_writes_all_files() {
1322 let tmp = tempfile::tempdir().expect("tempdir");
1323 let store = Arc::new(make_test_store_with_dump(tmp.path()).await);
1324
1325 let handles: Vec<_> = (0..4)
1326 .map(|i| {
1327 let s = store.clone();
1328 tokio::spawn(async move {
1329 s.create(serde_json::json!({
1330 "title": format!("concurrent-{i}"),
1331 "body": format!("body-{i}"),
1332 }))
1333 .await
1334 })
1335 })
1336 .collect();
1337
1338 let results: Vec<_> = futures::future::join_all(handles).await;
1339 let rows: Vec<_> = results
1341 .into_iter()
1342 .map(|r| r.expect("spawn ok").expect("create ok"))
1343 .collect();
1344
1345 for row in &rows {
1347 let path = tmp.path().join(format!("{}.md", row.id));
1348 assert!(path.exists(), "dump file must exist for row {}", row.id);
1349 }
1350 assert_eq!(rows.len(), 4);
1351 }
1352
1353 #[tokio::test]
1354 async fn store_open_with_bidirectional_sync_returns_ok() {
1355 use crate::dump::{DumpConfig, SyncMode};
1356 let schema = SchemaConfig {
1359 table: "test".into(),
1360 title: None,
1361 description: None,
1362 fields: vec![FieldDef {
1363 name: "title".into(),
1364 ty: FieldType::String,
1365 required: false,
1366 description: None,
1367 }],
1368 dump: Some(DumpConfig {
1369 dir: None,
1370 title_field: None,
1371 body_field: None,
1372 sync: Some(SyncMode::Bidirectional),
1373 }),
1374 };
1375 let store = Store::open(Path::new(":memory:"), schema).await;
1376 assert!(
1377 store.is_ok(),
1378 "Store::open must succeed even with bidirectional sync configured"
1379 );
1380 }
1381
1382 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1390 async fn test_savepoint_atomic_rollback_on_op_failure() {
1391 let store = make_test_store().await;
1392
1393 let result: Result<(), MiniAppError> = store
1395 .execute_under_savepoint(|sp| {
1396 sp.execute(
1397 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
1398 rusqlite::params!["sp-test-id", r#"{"title":"t"}"#, 1000_i64, 1000_i64],
1399 )?;
1400 Err(MiniAppError::Validation {
1402 field: "test".into(),
1403 reason: "forced rollback".into(),
1404 })
1405 })
1406 .await;
1407
1408 assert!(
1409 result.is_err(),
1410 "execute_under_savepoint must propagate the closure error"
1411 );
1412 assert!(
1413 matches!(result.unwrap_err(), MiniAppError::Validation { .. }),
1414 "error variant must be preserved"
1415 );
1416
1417 let rows = store.list(Some(1000), None, None).await.unwrap();
1419 assert_eq!(
1420 rows.len(),
1421 0,
1422 "SAVEPOINT rollback must revert the INSERT (Crux: SAVEPOINT atomicity)"
1423 );
1424
1425 store
1427 .create(serde_json::json!({"title": "after-rollback"}))
1428 .await
1429 .expect("store must be usable after SAVEPOINT rollback");
1430 assert_eq!(store.list(None, None, None).await.unwrap().len(), 1);
1431 }
1432
1433 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1435 async fn test_savepoint_commit_on_success() {
1436 let store = make_test_store().await;
1437
1438 let result = store
1439 .execute_under_savepoint(|sp| {
1440 sp.execute(
1441 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
1442 rusqlite::params!["sp-ok-id", r#"{"title":"committed"}"#, 1000_i64, 1000_i64],
1443 )?;
1444 Ok(42_u32)
1445 })
1446 .await;
1447
1448 assert_eq!(
1449 result.unwrap(),
1450 42_u32,
1451 "successful SAVEPOINT must return value"
1452 );
1453
1454 let rows = store.list(Some(10), None, None).await.unwrap();
1456 assert_eq!(rows.len(), 1, "committed INSERT must persist");
1457 }
1458
1459 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1462 async fn test_store_concurrent_create() {
1463 let store = Arc::new(make_test_store().await);
1464 let task_count = 8_usize;
1465 let rows_per_task = 100_usize;
1466
1467 let handles: Vec<_> = (0..task_count)
1468 .map(|task_id| {
1469 let s = Arc::clone(&store);
1470 tokio::spawn(async move {
1471 for i in 0..rows_per_task {
1472 s.create(serde_json::json!({"title": format!("task-{task_id}-row-{i}")}))
1473 .await
1474 .expect("concurrent create must succeed");
1475 }
1476 })
1477 })
1478 .collect();
1479
1480 futures::future::join_all(handles)
1481 .await
1482 .into_iter()
1483 .for_each(|r| r.expect("task must not panic"));
1484
1485 let total = store.list(Some(1000), None, None).await.unwrap().len();
1486 assert_eq!(
1487 total,
1488 task_count * rows_per_task,
1489 "all {total} rows must be present; expected {}",
1490 task_count * rows_per_task
1491 );
1492 }
1493
1494 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1497 async fn test_store_concurrent_update_same_id() {
1498 let store = Arc::new(make_test_store().await);
1499
1500 let row = store
1502 .create(serde_json::json!({"title": "initial"}))
1503 .await
1504 .unwrap();
1505 let id = row.id.clone();
1506
1507 let task_count = 4_usize;
1508 let updates_per_task = 50_usize;
1509
1510 let handles: Vec<_> = (0..task_count)
1511 .map(|task_id| {
1512 let s = Arc::clone(&store);
1513 let row_id = id.clone();
1514 tokio::spawn(async move {
1515 for i in 0..updates_per_task {
1516 s.update(
1517 &row_id,
1518 serde_json::json!({"title": format!("task-{task_id}-update-{i}")}),
1519 UpdateMode::Replace,
1520 )
1521 .await
1522 .expect("concurrent update must succeed");
1523 }
1524 })
1525 })
1526 .collect();
1527
1528 futures::future::join_all(handles)
1529 .await
1530 .into_iter()
1531 .for_each(|r| r.expect("task must not panic"));
1532
1533 let rows = store.list(None, None, None).await.unwrap();
1535 assert_eq!(rows.len(), 1, "update must not insert extra rows");
1536 assert!(
1537 rows[0].data["title"].is_string(),
1538 "title must be a string after concurrent updates"
1539 );
1540 }
1541
1542 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1547 async fn test_store_mutex_poison_propagated_as_error() {
1548 let store = Arc::new(make_test_store().await);
1549
1550 let conn = store.conn.clone();
1552 let _ = tokio::task::spawn_blocking(move || {
1553 let _guard = conn.lock().unwrap(); panic!("intentional poison"); })
1556 .await; let err = store.get("any-id").await.unwrap_err();
1560 assert!(
1561 matches!(&err, MiniAppError::Schema(msg) if msg.contains("mutex poisoned")),
1562 "expected Schema(\"mutex poisoned\"), got: {err:?}"
1563 );
1564 }
1565
1566 #[tokio::test]
1570 async fn store_open_sets_wal_journal_mode() {
1571 let tmp = tempfile::tempdir().expect("tempdir");
1572 let db_path = tmp.path().join("test.db");
1573
1574 let schema = SchemaConfig {
1575 table: "test".into(),
1576 title: None,
1577 description: None,
1578 fields: vec![FieldDef {
1579 name: "title".into(),
1580 ty: FieldType::String,
1581 required: false,
1582 description: None,
1583 }],
1584 dump: None,
1585 };
1586 let store = Store::open(&db_path, schema)
1587 .await
1588 .expect("Store::open should succeed");
1589
1590 let mode = {
1592 let conn = store.conn.lock().expect("lock");
1593 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
1594 .expect("PRAGMA journal_mode query")
1595 };
1596 assert_eq!(
1597 mode.to_lowercase(),
1598 "wal",
1599 "Store::open must set journal_mode = WAL for dual-registry safety (Crux #1)"
1600 );
1601 }
1602
1603 fn make_schema(fields: Vec<FieldDef>) -> SchemaConfig {
1607 SchemaConfig {
1608 table: "test".into(),
1609 title: None,
1610 description: None,
1611 fields,
1612 dump: None,
1613 }
1614 }
1615
1616 #[test]
1618 fn shallow_merge_preserves_absent_fields() {
1619 let schema = make_schema(vec![
1620 FieldDef {
1621 name: "a".into(),
1622 ty: FieldType::Number,
1623 required: true,
1624 description: None,
1625 },
1626 FieldDef {
1627 name: "b".into(),
1628 ty: FieldType::Number,
1629 required: false,
1630 description: None,
1631 },
1632 ]);
1633 let current = serde_json::json!({"a": 1, "b": 2});
1634 let patch = serde_json::json!({"a": 9});
1635 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
1636 assert_eq!(merged["a"], 9, "patched field must be updated");
1637 assert_eq!(
1638 merged["b"], 2,
1639 "absent patch field must be preserved from current"
1640 );
1641 }
1642
1643 #[test]
1645 fn shallow_merge_deletes_null_for_optional_field() {
1646 let schema = make_schema(vec![
1647 FieldDef {
1648 name: "a".into(),
1649 ty: FieldType::Number,
1650 required: true,
1651 description: None,
1652 },
1653 FieldDef {
1654 name: "b".into(),
1655 ty: FieldType::Number,
1656 required: false,
1657 description: None,
1658 },
1659 ]);
1660 let current = serde_json::json!({"a": 1, "b": 2});
1661 let patch = serde_json::json!({"b": null});
1662 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
1663 assert_eq!(merged["a"], 1);
1664 assert!(
1665 merged.get("b").is_none(),
1666 "null-patched optional field must be physically removed (not set to null)"
1667 );
1668 }
1669
1670 #[test]
1672 fn shallow_merge_errors_on_null_for_required_field() {
1673 let schema = make_schema(vec![FieldDef {
1674 name: "title".into(),
1675 ty: FieldType::String,
1676 required: true,
1677 description: None,
1678 }]);
1679 let current = serde_json::json!({"title": "hello"});
1680 let patch = serde_json::json!({"title": null});
1681 let err = shallow_merge(current, patch, &schema).expect_err("must error");
1682 match err {
1683 MiniAppError::Validation { field, reason } => {
1684 assert_eq!(field, "title");
1685 assert!(
1686 reason.contains("required field cannot be deleted via null"),
1687 "unexpected reason: {reason}"
1688 );
1689 }
1690 other => panic!("expected Validation error, got: {other:?}"),
1691 }
1692 }
1693
1694 #[test]
1696 fn shallow_merge_replaces_nested_object_wholesale() {
1697 let schema = make_schema(vec![FieldDef {
1698 name: "cfg".into(),
1699 ty: FieldType::Object,
1700 required: false,
1701 description: None,
1702 }]);
1703 let current = serde_json::json!({"cfg": {"x": 1, "y": 2}});
1704 let patch = serde_json::json!({"cfg": {"x": 9}});
1705 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
1706 assert_eq!(merged["cfg"]["x"], 9, "x must be updated");
1707 assert!(
1708 merged["cfg"].get("y").is_none(),
1709 "y must be absent (nested object replaced wholesale, not deep-merged)"
1710 );
1711 }
1712
1713 #[test]
1715 fn shallow_merge_rejects_non_object_patch() {
1716 let schema = make_schema(vec![]);
1717 let current = serde_json::json!({"a": 1});
1718
1719 for bad_patch in [
1720 serde_json::json!([1, 2, 3]),
1721 serde_json::json!(42),
1722 serde_json::json!("string"),
1723 ] {
1724 let err = shallow_merge(current.clone(), bad_patch, &schema)
1725 .expect_err("non-object patch must be rejected");
1726 match err {
1727 MiniAppError::Validation { field, .. } => {
1728 assert_eq!(field, "(root)", "error field must be '(root)'");
1729 }
1730 other => panic!("expected Validation error, got: {other:?}"),
1731 }
1732 }
1733 }
1734
1735 #[tokio::test]
1738 async fn store_update_merge_runs_post_merge_validation() {
1739 let store = make_test_store().await;
1740 let row = store
1741 .create(serde_json::json!({"title": "x", "state": "open"}))
1742 .await
1743 .unwrap();
1744
1745 let err = store
1747 .update(&row.id, serde_json::json!({"state": 42}), UpdateMode::Merge)
1748 .await
1749 .expect_err("type mismatch must fail post-merge validation");
1750
1751 assert!(
1752 matches!(err, MiniAppError::Validation { .. }),
1753 "expected Validation error, got: {err:?}"
1754 );
1755 }
1756
1757 use crate::filter::ListFilter;
1762
1763 fn make_filter() -> ListFilter {
1765 ListFilter::Eq {
1766 field: "state".to_string(),
1767 value: serde_json::json!("open"),
1768 }
1769 }
1770
1771 #[tokio::test]
1773 async fn alias_create_and_get_round_trip() {
1774 let store = make_test_store().await;
1775 let filter = make_filter();
1776 let filter_json = serde_json::to_string(&filter).unwrap();
1777
1778 store
1779 .alias_create(
1780 "recent_open",
1781 &filter_json,
1782 Some(20),
1783 Some("desc".to_string()),
1784 None,
1785 )
1786 .await
1787 .expect("alias_create must succeed");
1788
1789 let record = store
1790 .alias_get("recent_open")
1791 .await
1792 .expect("alias_get must succeed");
1793
1794 assert_eq!(record.name, "recent_open");
1795 assert_eq!(record.default_limit, Some(20));
1796 assert_eq!(record.description.as_deref(), Some("desc"));
1797
1798 let restored: ListFilter =
1800 serde_json::from_str(&record.filter).expect("filter must deserialise");
1801 let stored_back = serde_json::to_string(&filter).unwrap();
1802 let stored_back2 = serde_json::to_string(&restored).unwrap();
1803 assert_eq!(
1804 stored_back, stored_back2,
1805 "filter must survive a JSON round-trip"
1806 );
1807 }
1808
1809 #[tokio::test]
1811 async fn alias_create_with_optional_nulls() {
1812 let store = make_test_store().await;
1813 let filter = make_filter();
1814 let filter_json = serde_json::to_string(&filter).unwrap();
1815
1816 store
1817 .alias_create("no_opts", &filter_json, None, None, None)
1818 .await
1819 .expect("alias_create must succeed with None optionals");
1820
1821 let record = store
1822 .alias_get("no_opts")
1823 .await
1824 .expect("alias_get must succeed");
1825 assert_eq!(record.name, "no_opts");
1826 assert!(record.default_limit.is_none());
1827 assert!(record.description.is_none());
1828 }
1829
1830 #[tokio::test]
1832 async fn alias_list_returns_all() {
1833 let store = make_test_store().await;
1834 let filter = make_filter();
1835
1836 let list = store
1838 .alias_list()
1839 .await
1840 .expect("alias_list must succeed on empty store");
1841 assert!(list.is_empty(), "empty store should return empty list");
1842
1843 let filter_json = serde_json::to_string(&filter).unwrap();
1844 store
1845 .alias_create("b_alias", &filter_json, None, None, None)
1846 .await
1847 .unwrap();
1848 store
1849 .alias_create("a_alias", &filter_json, None, None, None)
1850 .await
1851 .unwrap();
1852
1853 let list = store.alias_list().await.expect("alias_list must succeed");
1854 assert_eq!(list.len(), 2, "must return 2 aliases");
1855 assert_eq!(list[0].name, "a_alias");
1857 assert_eq!(list[1].name, "b_alias");
1858 }
1859
1860 #[tokio::test]
1862 async fn alias_delete_removes_alias() {
1863 let store = make_test_store().await;
1864 let filter = make_filter();
1865 let filter_json = serde_json::to_string(&filter).unwrap();
1866
1867 store
1868 .alias_create("to_delete", &filter_json, None, None, None)
1869 .await
1870 .unwrap();
1871
1872 store
1873 .alias_delete("to_delete")
1874 .await
1875 .expect("alias_delete must succeed");
1876
1877 let err = store
1878 .alias_get("to_delete")
1879 .await
1880 .expect_err("alias_get after delete must fail");
1881
1882 assert!(
1883 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "to_delete"),
1884 "expected AliasNotFound, got: {err:?}"
1885 );
1886 }
1887
1888 #[tokio::test]
1890 async fn alias_create_duplicate_returns_already_exists() {
1891 let store = make_test_store().await;
1892 let filter = make_filter();
1893 let filter_json = serde_json::to_string(&filter).unwrap();
1894
1895 store
1896 .alias_create("dup", &filter_json, None, None, None)
1897 .await
1898 .expect("first alias_create must succeed");
1899
1900 let err = store
1901 .alias_create("dup", &filter_json, None, None, None)
1902 .await
1903 .expect_err("second alias_create must fail");
1904
1905 assert!(
1906 matches!(err, MiniAppError::AliasAlreadyExists { ref name } if name == "dup"),
1907 "expected AliasAlreadyExists, got: {err:?}"
1908 );
1909 }
1910
1911 #[tokio::test]
1913 async fn alias_get_missing_returns_not_found() {
1914 let store = make_test_store().await;
1915
1916 let err = store
1917 .alias_get("nonexistent")
1918 .await
1919 .expect_err("alias_get on missing alias must fail");
1920
1921 assert!(
1922 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
1923 "expected AliasNotFound, got: {err:?}"
1924 );
1925 }
1926
1927 #[tokio::test]
1929 async fn alias_delete_missing_returns_not_found() {
1930 let store = make_test_store().await;
1931
1932 let err = store
1933 .alias_delete("nonexistent")
1934 .await
1935 .expect_err("alias_delete on missing alias must fail");
1936
1937 assert!(
1938 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
1939 "expected AliasNotFound, got: {err:?}"
1940 );
1941 }
1942
1943 #[tokio::test]
1946 async fn alias_namespace_isolation_between_stores() {
1947 let store_a = make_test_store().await;
1948 let store_b = make_test_store().await;
1949 let filter = make_filter();
1950
1951 let filter_json = serde_json::to_string(&filter).unwrap();
1952 store_a
1953 .alias_create("shared_name", &filter_json, None, None, None)
1954 .await
1955 .expect("store_a alias_create must succeed");
1956
1957 let err = store_b
1959 .alias_get("shared_name")
1960 .await
1961 .expect_err("alias created in store_a must not be visible in store_b");
1962
1963 assert!(
1964 matches!(err, MiniAppError::AliasNotFound { .. }),
1965 "expected AliasNotFound in store_b, got: {err:?}"
1966 );
1967 }
1968
1969 #[tokio::test]
1973 async fn test_get_prefix_match_single() {
1974 let store = make_test_store().await;
1975 let row = store
1976 .create(serde_json::json!({"title": "prefix-test"}))
1977 .await
1978 .unwrap();
1979 let prefix = &row.id[..8];
1981 let fetched = store.get(prefix).await.unwrap();
1982 assert_eq!(fetched.id, row.id);
1983 assert_eq!(fetched.data["title"], "prefix-test");
1984 }
1985
1986 #[tokio::test]
1988 async fn test_get_prefix_match_not_found() {
1989 let store = make_test_store().await;
1990 let err = store.get("zzzzzzzz").await.unwrap_err();
1992 assert!(
1993 matches!(err, MiniAppError::NotFound { .. }),
1994 "expected NotFound, got: {err:?}"
1995 );
1996 }
1997
1998 #[tokio::test]
2000 async fn test_get_prefix_match_ambiguous() {
2001 let store = make_test_store().await;
2002 let id1 = "aaaaaaaa-0000-4000-8000-000000000001".to_string();
2005 let id2 = "aaaaaaaa-0000-4000-8000-000000000002".to_string();
2006 let id1_clone = id1.clone();
2007 let id2_clone = id2.clone();
2008 store
2009 .execute_under_savepoint(move |sp| {
2010 sp.execute(
2011 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2012 rusqlite::params![id1_clone, r#"{"title":"a1"}"#],
2013 )?;
2014 sp.execute(
2015 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2016 rusqlite::params![id2_clone, r#"{"title":"a2"}"#],
2017 )?;
2018 Ok(())
2019 })
2020 .await
2021 .unwrap();
2022
2023 let err = store.get("aaaaaaaa").await.unwrap_err();
2024 match err {
2025 MiniAppError::AmbiguousId {
2026 ref id_prefix,
2027 ref candidates,
2028 } => {
2029 assert_eq!(id_prefix, "aaaaaaaa");
2030 assert_eq!(candidates.len(), 2);
2031 let mut sorted = candidates.clone();
2032 sorted.sort();
2033 assert_eq!(sorted[0], id1);
2034 assert_eq!(sorted[1], id2);
2035 }
2036 other => panic!("expected AmbiguousId, got: {other:?}"),
2037 }
2038 }
2039
2040 #[tokio::test]
2042 async fn test_get_full_uuid_bypass() {
2043 let store = make_test_store().await;
2044 let row = store
2045 .create(serde_json::json!({"title": "bypass-test"}))
2046 .await
2047 .unwrap();
2048 assert_eq!(row.id.len(), 36, "UUID must be 36 chars");
2049 let fetched = store.get(&row.id).await.unwrap();
2051 assert_eq!(fetched.id, row.id);
2052 }
2053
2054 #[tokio::test]
2056 async fn test_update_prefix_match_single() {
2057 let store = make_test_store().await;
2058 let row = store
2059 .create(serde_json::json!({"title": "before"}))
2060 .await
2061 .unwrap();
2062 let prefix = &row.id[..8];
2063 let updated = store
2064 .update(
2065 prefix,
2066 serde_json::json!({"title": "after"}),
2067 UpdateMode::Replace,
2068 )
2069 .await
2070 .unwrap();
2071 assert_eq!(updated.id, row.id);
2072 assert_eq!(updated.data["title"], "after");
2073 }
2074
2075 #[tokio::test]
2077 async fn test_update_prefix_match_ambiguous() {
2078 let store = make_test_store().await;
2079 let id1 = "bbbbbbbb-0000-4000-8000-000000000001".to_string();
2080 let id2 = "bbbbbbbb-0000-4000-8000-000000000002".to_string();
2081 store
2082 .execute_under_savepoint(move |sp| {
2083 sp.execute(
2084 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2085 rusqlite::params![id1, r#"{"title":"b1"}"#],
2086 )?;
2087 sp.execute(
2088 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2089 rusqlite::params![id2, r#"{"title":"b2"}"#],
2090 )?;
2091 Ok(())
2092 })
2093 .await
2094 .unwrap();
2095
2096 let err = store
2097 .update(
2098 "bbbbbbbb",
2099 serde_json::json!({"title": "x"}),
2100 UpdateMode::Replace,
2101 )
2102 .await
2103 .unwrap_err();
2104 assert!(
2105 matches!(err, MiniAppError::AmbiguousId { .. }),
2106 "expected AmbiguousId, got: {err:?}"
2107 );
2108 }
2109
2110 #[tokio::test]
2112 async fn test_delete_prefix_match_single() {
2113 let store = make_test_store().await;
2114 let row = store
2115 .create(serde_json::json!({"title": "to-delete-prefix"}))
2116 .await
2117 .unwrap();
2118 let prefix = &row.id[..8];
2119 store.delete(prefix).await.unwrap();
2120 let err = store.get(&row.id).await.unwrap_err();
2122 assert!(
2123 matches!(err, MiniAppError::NotFound { .. }),
2124 "expected NotFound after delete, got: {err:?}"
2125 );
2126 }
2127
2128 #[tokio::test]
2130 async fn test_delete_prefix_match_ambiguous() {
2131 let store = make_test_store().await;
2132 let id1 = "cccccccc-0000-4000-8000-000000000001".to_string();
2133 let id2 = "cccccccc-0000-4000-8000-000000000002".to_string();
2134 store
2135 .execute_under_savepoint(move |sp| {
2136 sp.execute(
2137 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2138 rusqlite::params![id1, r#"{"title":"c1"}"#],
2139 )?;
2140 sp.execute(
2141 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2142 rusqlite::params![id2, r#"{"title":"c2"}"#],
2143 )?;
2144 Ok(())
2145 })
2146 .await
2147 .unwrap();
2148
2149 let err = store.delete("cccccccc").await.unwrap_err();
2150 assert!(
2151 matches!(err, MiniAppError::AmbiguousId { .. }),
2152 "expected AmbiguousId, got: {err:?}"
2153 );
2154 }
2155}