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::order_by::OrderByItem;
25use crate::schema::{FieldType, SchemaConfig};
26
27#[derive(Debug, Clone, Serialize)]
37pub struct RowRecord {
38 pub id: String,
40 pub data: serde_json::Value,
42 pub created_at: i64,
44 pub updated_at: i64,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
59#[serde(rename_all = "lowercase")]
60pub enum UpdateMode {
61 #[default]
63 Merge,
64 Replace,
66}
67
68pub struct Store {
77 conn: Arc<Mutex<rusqlite::Connection>>,
78 schema: SchemaConfig,
79 db_path: PathBuf,
86}
87
88const CREATE_TABLE_SQL: &str = "
94 CREATE TABLE IF NOT EXISTS rows (
95 id TEXT PRIMARY KEY,
96 data TEXT NOT NULL,
97 created_at INTEGER NOT NULL,
98 updated_at INTEGER NOT NULL
99 )
100";
101
102const CREATE_ALIASES_TABLE_SQL: &str = "
115 CREATE TABLE IF NOT EXISTS _aliases (
116 name TEXT PRIMARY KEY,
117 filter TEXT NOT NULL,
118 default_limit INTEGER,
119 description TEXT,
120 params_schema TEXT
121 )
122";
123
124#[derive(Debug, Clone)]
130pub struct AliasRecord {
131 pub name: String,
133 pub filter: String,
136 pub default_limit: Option<u32>,
138 pub description: Option<String>,
140 pub params_schema: Option<String>,
143}
144
145#[derive(Debug, serde::Serialize)]
147pub struct ReplaceResult {
148 pub matches: u32,
150}
151
152fn now_secs() -> i64 {
158 SystemTime::now()
159 .duration_since(UNIX_EPOCH)
160 .unwrap_or_default()
161 .as_secs() as i64
162}
163
164fn parse_data(json_str: &str) -> Result<serde_json::Value, MiniAppError> {
166 serde_json::from_str(json_str).map_err(|e| MiniAppError::Schema(format!("data column: {e}")))
167}
168
169fn resolve_id(conn: &rusqlite::Connection, id: &str) -> Result<String, MiniAppError> {
185 if id.len() == 36 {
186 return Ok(id.to_string());
188 }
189 let mut stmt = conn.prepare("SELECT id FROM rows WHERE id LIKE ?1")?;
190 let candidates: Vec<String> = stmt
191 .query_map(rusqlite::params![format!("{}%", id)], |row| {
192 row.get::<_, String>(0)
193 })?
194 .collect::<Result<Vec<_>, _>>()?;
195 match candidates.len() {
196 0 => Err(MiniAppError::NotFound { id: id.to_string() }),
197 1 => {
198 Ok(candidates.into_iter().next().unwrap())
200 }
201 _ => Err(MiniAppError::AmbiguousId {
202 id_prefix: id.to_string(),
203 candidates,
204 }),
205 }
206}
207
208fn shallow_merge(
225 mut current: serde_json::Value,
226 patch: serde_json::Value,
227 schema: &SchemaConfig,
228) -> Result<serde_json::Value, MiniAppError> {
229 let patch_map = patch.as_object().ok_or_else(|| MiniAppError::Validation {
230 field: "(root)".to_string(),
231 reason: "patch must be a JSON object".to_string(),
232 })?;
233
234 let current_map = current
235 .as_object_mut()
236 .ok_or_else(|| MiniAppError::Validation {
237 field: "(root)".to_string(),
238 reason: "stored row is not a JSON object".to_string(),
239 })?;
240
241 for (key, value) in patch_map {
242 if value.is_null() {
243 let is_required = schema
245 .fields
246 .iter()
247 .find(|f| &f.name == key)
248 .map(|f| f.required)
249 .unwrap_or(false);
250
251 if is_required {
252 return Err(MiniAppError::Validation {
253 field: key.clone(),
254 reason: "required field cannot be deleted via null".to_string(),
255 });
256 }
257 current_map.remove(key);
258 } else {
259 current_map.insert(key.clone(), value.clone());
260 }
261 }
262
263 Ok(current)
264}
265
266impl Store {
271 pub async fn open(db_path: &Path, schema: SchemaConfig) -> Result<Self, MiniAppError> {
304 if let Some(crate::dump::SyncMode::Bidirectional) =
306 schema.dump.as_ref().and_then(|d| d.sync.as_ref())
307 {
308 tracing::warn!(
309 target: "mini_app_mcp::dump",
310 "sync=bidirectional configured but not implemented yet; behaving as write-only"
311 );
312 }
313
314 let stored_db_path = db_path.to_path_buf();
315 let db_path = db_path.to_path_buf();
316 let conn =
317 tokio::task::spawn_blocking(move || -> Result<rusqlite::Connection, MiniAppError> {
318 let c = rusqlite::Connection::open(&db_path)?;
319 c.pragma_update(None, "journal_mode", "WAL")?;
322 let actual_mode: String = c.query_row("PRAGMA journal_mode", [], |r| r.get(0))?;
326 if actual_mode.to_lowercase() != "wal" {
327 tracing::warn!(
328 actual_mode = %actual_mode,
329 "PRAGMA journal_mode=WAL fell back to non-WAL mode; \
330 concurrent reload may hit SQLITE_BUSY"
331 );
332 }
333 c.execute_batch(CREATE_TABLE_SQL)?;
334 c.execute_batch(CREATE_ALIASES_TABLE_SQL)?;
335 crate::row_history::ensure_history_table(&c)?;
336 let has_params_schema = c
340 .prepare("PRAGMA table_info(_aliases)")?
341 .query_map([], |row| row.get::<_, String>(1))?
342 .collect::<Result<Vec<_>, _>>()?
343 .iter()
344 .any(|name| name == "params_schema");
345 if !has_params_schema {
346 c.execute_batch("ALTER TABLE _aliases ADD COLUMN params_schema TEXT")?;
347 }
348 Ok(c)
349 })
350 .await
351 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
352
353 Ok(Store {
354 conn: Arc::new(Mutex::new(conn)),
355 schema,
356 db_path: stored_db_path,
357 })
358 }
359
360 pub fn db_path(&self) -> &Path {
367 &self.db_path
368 }
369
370 pub fn conn(&self) -> Arc<Mutex<rusqlite::Connection>> {
379 Arc::clone(&self.conn)
380 }
381
382 pub fn conn_for_test(&self) -> std::sync::MutexGuard<'_, rusqlite::Connection> {
387 self.conn.lock().expect("not poisoned")
388 }
389
390 pub async fn create(&self, value: serde_json::Value) -> Result<RowRecord, MiniAppError> {
421 self.schema.validate(&value)?;
422
423 let id = uuid::Uuid::new_v4().to_string();
424 let now = now_secs();
425 let data_str =
426 serde_json::to_string(&value).expect("serde_json::Value serialization is infallible");
427
428 let table_name = self.schema.table.clone();
429 let conn = self.conn.clone();
430 let id_inner = id.clone();
431 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
432 let mut conn = conn
433 .lock()
434 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
435 let tx = conn.transaction()?;
436 tx.execute(
437 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
438 rusqlite::params![id_inner, data_str, now, now],
439 )?;
440 crate::row_history::record_in_tx(
441 &tx,
442 &table_name,
443 &id_inner,
444 crate::row_history::HistoryOp::Create,
445 Some(&serde_json::to_string(&value).expect("infallible")),
446 None,
447 now,
448 )?;
449 tx.commit()?;
450 Ok(RowRecord {
451 id: id_inner,
452 data: value,
453 created_at: now,
454 updated_at: now,
455 })
456 })
457 .await
458 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
459
460 crate::dump::on_change(&self.schema, &record).await?;
462
463 Ok(record)
464 }
465
466 pub async fn get(&self, id: &str) -> Result<RowRecord, MiniAppError> {
485 let conn = self.conn.clone();
486 let id = id.to_string();
487
488 tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
489 let conn = conn
490 .lock()
491 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
492 let id = resolve_id(&conn, &id)?;
493 let mut stmt =
494 conn.prepare("SELECT id, data, created_at, updated_at FROM rows WHERE id = ?1")?;
495 let row = stmt
496 .query_row(rusqlite::params![id], |row| {
497 Ok((
498 row.get::<_, String>(0)?,
499 row.get::<_, String>(1)?,
500 row.get::<_, i64>(2)?,
501 row.get::<_, i64>(3)?,
502 ))
503 })
504 .optional()?
505 .ok_or_else(|| MiniAppError::NotFound { id: id.clone() })?;
506
507 let data = parse_data(&row.1)?;
508 Ok(RowRecord {
509 id: row.0,
510 data,
511 created_at: row.2,
512 updated_at: row.3,
513 })
514 })
515 .await
516 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
517 }
518
519 pub async fn list(
540 &self,
541 limit: Option<u32>,
542 offset: Option<u32>,
543 filter: Option<ListFilter>,
544 order_by: Option<Vec<OrderByItem>>,
545 ) -> Result<Vec<RowRecord>, MiniAppError> {
546 let conn = self.conn.clone();
547 let limit = limit.unwrap_or(100).min(1000) as i64;
548 let offset = offset.unwrap_or(0) as i64;
549
550 let (where_clause, filter_params) = match filter {
552 None => (String::new(), Vec::new()),
553 Some(f) => {
554 let (fragment, params) = f.build_sql()?;
555 (format!(" WHERE {fragment}"), params)
556 }
557 };
558
559 let order_by_clause = match &order_by {
564 None => " ORDER BY created_at DESC".to_string(),
565 Some(items) if items.is_empty() => " ORDER BY created_at DESC".to_string(),
566 Some(items) => format!(" ORDER BY {}", crate::order_by::build_order_by_sql(items)),
567 };
568
569 tokio::task::spawn_blocking(move || -> Result<Vec<RowRecord>, MiniAppError> {
570 let conn = conn
571 .lock()
572 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
573 let sql = format!(
574 "SELECT id, data, created_at, updated_at FROM rows{where_clause}{order_by_clause} LIMIT ? OFFSET ?"
575 );
576 let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = filter_params
578 .into_iter()
579 .map(|p| -> Box<dyn rusqlite::ToSql> { Box::new(p) })
580 .collect();
581 all_params.push(Box::new(limit));
582 all_params.push(Box::new(offset));
583
584 let mut stmt = conn.prepare(&sql)?;
585 let rows = stmt
586 .query_map(
587 params_from_iter(all_params.iter().map(|p| p.as_ref())),
588 |row| {
589 Ok((
590 row.get::<_, String>(0)?,
591 row.get::<_, String>(1)?,
592 row.get::<_, i64>(2)?,
593 row.get::<_, i64>(3)?,
594 ))
595 },
596 )?
597 .map(|r| {
598 r.map_err(MiniAppError::Storage).and_then(|row| {
599 let data = parse_data(&row.1)?;
600 Ok(RowRecord {
601 id: row.0,
602 data,
603 created_at: row.2,
604 updated_at: row.3,
605 })
606 })
607 })
608 .collect::<Result<Vec<_>, _>>()?;
609 Ok(rows)
610 })
611 .await
612 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
613 }
614
615 pub async fn row_count(&self) -> Result<u64, MiniAppError> {
628 let conn = self.conn.clone();
629 tokio::task::spawn_blocking(move || -> Result<u64, MiniAppError> {
630 let conn = conn
631 .lock()
632 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
633 let count: i64 = conn.query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))?;
634 Ok(count.max(0) as u64)
635 })
636 .await
637 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
638 }
639
640 pub async fn update(
684 &self,
685 id: &str,
686 value: serde_json::Value,
687 mode: UpdateMode,
688 ) -> Result<RowRecord, MiniAppError> {
689 let now = now_secs();
690 let conn = self.conn.clone();
691 let id_str = id.to_string();
692 let schema = self.schema.clone();
693 let table_name = self.schema.table.clone();
694
695 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
696 let mut conn = conn
697 .lock()
698 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
699 let id_str = resolve_id(&conn, &id_str)?;
700
701 let row_data: Option<(String, i64)> = conn
705 .query_row(
706 "SELECT data, created_at FROM rows WHERE id = ?1",
707 rusqlite::params![id_str],
708 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
709 )
710 .optional()?;
711
712 let (current_data_str, created_at) =
713 row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
714
715 let merged = match mode {
716 UpdateMode::Merge => {
717 let current: serde_json::Value = parse_data(¤t_data_str)?;
718 let merged = shallow_merge(current, value, &schema)?;
719 schema.validate(&merged)?;
721 merged
722 }
723 UpdateMode::Replace => {
724 schema.validate(&value)?;
727 value
728 }
729 };
730
731 let merged_str = serde_json::to_string(&merged)
732 .expect("serde_json::Value serialization is infallible");
733
734 let tx = conn.transaction()?;
735 tx.execute(
736 "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
737 rusqlite::params![merged_str, now, id_str],
738 )?;
739 crate::row_history::record_in_tx(
740 &tx,
741 &table_name,
742 &id_str,
743 crate::row_history::HistoryOp::Update,
744 Some(&merged_str),
745 Some(¤t_data_str),
746 now,
747 )?;
748 tx.commit()?;
749
750 Ok(RowRecord {
751 id: id_str,
752 data: merged,
753 created_at,
754 updated_at: now,
755 })
756 })
757 .await
758 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
759
760 crate::dump::on_change(&self.schema, &record).await?;
762
763 Ok(record)
764 }
765
766 pub async fn execute_under_savepoint<F, R>(&self, f: F) -> Result<R, MiniAppError>
802 where
803 F: FnOnce(&mut rusqlite::Savepoint<'_>) -> Result<R, MiniAppError> + Send + 'static,
804 R: Send + 'static,
805 {
806 let conn = self.conn.clone();
807 tokio::task::spawn_blocking(move || -> Result<R, MiniAppError> {
808 let mut guard = conn
809 .lock()
810 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
811 let mut sp = guard.savepoint()?;
812 sp.set_drop_behavior(rusqlite::DropBehavior::Rollback);
814 let result = f(&mut sp)?;
815 sp.commit()?;
816 Ok(result)
817 })
818 .await
819 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
820 }
821
822 pub async fn delete(&self, id: &str) -> Result<(), MiniAppError> {
864 let conn = self.conn.clone();
865 let id = id.to_string();
866 let table_name = self.schema.table.clone();
867 let now = now_secs();
868
869 let resolved_id = tokio::task::spawn_blocking(move || -> Result<String, MiniAppError> {
872 let mut conn = conn
873 .lock()
874 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
875 let resolved = resolve_id(&conn, &id)?;
876 let data_str: Option<String> = conn
878 .query_row(
879 "SELECT data FROM rows WHERE id = ?1",
880 rusqlite::params![resolved],
881 |row| row.get::<_, String>(0),
882 )
883 .optional()?;
884 let data_str = data_str.ok_or_else(|| MiniAppError::NotFound {
885 id: resolved.clone(),
886 })?;
887 let tx = conn.transaction()?;
888 let n = tx.execute(
889 "DELETE FROM rows WHERE id = ?1",
890 rusqlite::params![resolved],
891 )?;
892 if n == 0 {
893 return Err(MiniAppError::NotFound { id: resolved });
895 }
896 crate::row_history::record_in_tx(
897 &tx,
898 &table_name,
899 &resolved,
900 crate::row_history::HistoryOp::Delete,
901 Some(&data_str),
902 None,
903 now,
904 )?;
905 tx.commit()?;
906 Ok(resolved)
907 })
908 .await
909 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
910
911 crate::dump::on_delete(&self.schema, &resolved_id).await?;
913
914 Ok(())
915 }
916
917 pub async fn restore_row(
933 &self,
934 id: &str,
935 data: serde_json::Value,
936 ) -> Result<RowRecord, MiniAppError> {
937 self.schema.validate(&data)?;
938 let now = now_secs();
939 let id_str = id.to_string();
940 let data_str =
941 serde_json::to_string(&data).expect("serde_json::Value serialization is infallible");
942 let table_name = self.schema.table.clone();
943 let conn = self.conn.clone();
944
945 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
946 let mut conn = conn
947 .lock()
948 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
949 let tx = conn.transaction()?;
950 tx.execute(
951 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
952 rusqlite::params![id_str, data_str, now, now],
953 )?;
954 crate::row_history::record_in_tx(
955 &tx,
956 &table_name,
957 &id_str,
958 crate::row_history::HistoryOp::Create,
959 Some(&data_str),
960 None,
961 now,
962 )?;
963 tx.commit()?;
964 Ok(RowRecord {
965 id: id_str,
966 data,
967 created_at: now,
968 updated_at: now,
969 })
970 })
971 .await
972 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
973
974 crate::dump::on_change(&self.schema, &record).await?;
975
976 Ok(record)
977 }
978
979 pub async fn fetch_history_at(
992 &self,
993 id: &str,
994 at_unix_secs: i64,
995 ) -> Result<Option<crate::row_history::HistoryRecord>, MiniAppError> {
996 let conn = self.conn.clone();
997 let table_name = self.schema.table.clone();
998 let id_str = id.to_string();
999 tokio::task::spawn_blocking(
1000 move || -> Result<Option<crate::row_history::HistoryRecord>, MiniAppError> {
1001 let conn = conn
1002 .lock()
1003 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1004 crate::row_history::fetch_at(&conn, &table_name, &id_str, at_unix_secs)
1005 .map_err(MiniAppError::Storage)
1006 },
1007 )
1008 .await
1009 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1010 }
1011
1012 pub async fn alias_create(
1032 &self,
1033 name: &str,
1034 filter_json: &str,
1035 default_limit: Option<u32>,
1036 description: Option<String>,
1037 params_schema: Option<String>,
1038 ) -> Result<(), MiniAppError> {
1039 let conn = self.conn.clone();
1040 let name = name.to_string();
1041 let filter_json = filter_json.to_string();
1042
1043 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
1044 let conn = conn
1045 .lock()
1046 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1047 conn.execute(
1048 "INSERT OR IGNORE INTO _aliases \
1049 (name, filter, default_limit, description, params_schema) \
1050 VALUES (?1, ?2, ?3, ?4, ?5)",
1051 rusqlite::params![name, filter_json, default_limit, description, params_schema],
1052 )?;
1053 if conn.changes() == 0 {
1054 return Err(MiniAppError::AliasAlreadyExists { name });
1055 }
1056 Ok(())
1057 })
1058 .await
1059 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1060 }
1061
1062 pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
1072 let conn = self.conn.clone();
1073 let name = name.to_string();
1074
1075 tokio::task::spawn_blocking(move || -> Result<AliasRecord, MiniAppError> {
1076 let conn = conn
1077 .lock()
1078 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1079 let mut stmt = conn.prepare(
1080 "SELECT name, filter, default_limit, description, params_schema \
1081 FROM _aliases WHERE name = ?1",
1082 )?;
1083 let record = stmt
1084 .query_row(rusqlite::params![name], |row| {
1085 Ok((
1086 row.get::<_, String>(0)?,
1087 row.get::<_, String>(1)?,
1088 row.get::<_, Option<u32>>(2)?,
1089 row.get::<_, Option<String>>(3)?,
1090 row.get::<_, Option<String>>(4)?,
1091 ))
1092 })
1093 .optional()?
1094 .ok_or_else(|| MiniAppError::AliasNotFound { name: name.clone() })?;
1095
1096 Ok(AliasRecord {
1097 name: record.0,
1098 filter: record.1,
1099 default_limit: record.2,
1100 description: record.3,
1101 params_schema: record.4,
1102 })
1103 })
1104 .await
1105 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1106 }
1107
1108 pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
1119 let conn = self.conn.clone();
1120
1121 tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
1122 let conn = conn
1123 .lock()
1124 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1125 let mut stmt = conn.prepare(
1126 "SELECT name, filter, default_limit, description, params_schema \
1127 FROM _aliases ORDER BY name ASC",
1128 )?;
1129 let records = stmt
1130 .query_map([], |row| {
1131 Ok((
1132 row.get::<_, String>(0)?,
1133 row.get::<_, String>(1)?,
1134 row.get::<_, Option<u32>>(2)?,
1135 row.get::<_, Option<String>>(3)?,
1136 row.get::<_, Option<String>>(4)?,
1137 ))
1138 })?
1139 .collect::<Result<Vec<_>, _>>()?;
1140
1141 Ok(records
1142 .into_iter()
1143 .map(
1144 |(name, filter, default_limit, description, params_schema)| AliasRecord {
1145 name,
1146 filter,
1147 default_limit,
1148 description,
1149 params_schema,
1150 },
1151 )
1152 .collect())
1153 })
1154 .await
1155 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1156 }
1157
1158 pub async fn alias_delete(&self, name: &str) -> Result<(), MiniAppError> {
1168 let conn = self.conn.clone();
1169 let name = name.to_string();
1170
1171 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
1172 let conn = conn
1173 .lock()
1174 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1175 let n = conn.execute(
1176 "DELETE FROM _aliases WHERE name = ?1",
1177 rusqlite::params![name],
1178 )?;
1179 if n == 0 {
1180 return Err(MiniAppError::AliasNotFound { name });
1181 }
1182 Ok(())
1183 })
1184 .await
1185 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1186 }
1187
1188 pub async fn view_string_field(
1202 &self,
1203 id: &str,
1204 field: &str,
1205 view_range: Option<(u32, u32)>,
1206 ) -> Result<String, MiniAppError> {
1207 let conn = self.conn.clone();
1208 let id_str = id.to_string();
1209 let field = field.to_string();
1210 let schema = self.schema.clone();
1211
1212 tokio::task::spawn_blocking(move || -> Result<String, MiniAppError> {
1213 let conn = conn
1214 .lock()
1215 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1216 let id_str = resolve_id(&conn, &id_str)?;
1217
1218 let field_def = schema
1219 .fields
1220 .iter()
1221 .find(|f| f.name == field)
1222 .ok_or_else(|| MiniAppError::Schema(format!("field `{field}` not in schema")))?;
1223 if field_def.ty != FieldType::String {
1224 return Err(MiniAppError::FieldTypeError {
1225 field: field.clone(),
1226 actual_type: field_def.ty.as_str().to_string(),
1227 });
1228 }
1229
1230 let row_data: Option<String> = conn
1231 .query_row(
1232 "SELECT data FROM rows WHERE id = ?1",
1233 rusqlite::params![id_str],
1234 |row| row.get::<_, String>(0),
1235 )
1236 .optional()?;
1237 let data_str = row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
1238
1239 let data = parse_data(&data_str)?;
1240 let text = data
1241 .get(&field)
1242 .and_then(|v| v.as_str())
1243 .unwrap_or("")
1244 .to_string();
1245
1246 let lines: Vec<&str> = text.split('\n').collect();
1247 let total = lines.len() as u32;
1248 let (start, end) = match view_range {
1249 Some((s, e)) => (s.max(1), e.min(total)),
1250 None => (1, total),
1251 };
1252
1253 let mut out = String::new();
1254 for (i, line) in lines.iter().enumerate() {
1255 let lineno = i as u32 + 1;
1256 if lineno >= start && lineno <= end {
1257 out.push_str(&format!("{:6} {}\n", lineno, line));
1258 }
1259 }
1260 Ok(out)
1261 })
1262 .await
1263 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1264 }
1265
1266 pub async fn replace_string_field(
1283 &self,
1284 id: &str,
1285 field: &str,
1286 old_str: &str,
1287 new_str: &str,
1288 view_range: Option<(u32, u32)>,
1289 replace_all: bool,
1290 ) -> Result<ReplaceResult, MiniAppError> {
1291 let now = now_secs();
1292 let conn = self.conn.clone();
1293 let id_str = id.to_string();
1294 let field = field.to_string();
1295 let old_str = old_str.to_string();
1296 let new_str = new_str.to_string();
1297 let schema = self.schema.clone();
1298 let table_name = self.schema.table.clone();
1299
1300 let (record, matches) =
1301 tokio::task::spawn_blocking(move || -> Result<(RowRecord, u32), MiniAppError> {
1302 let mut conn = conn
1303 .lock()
1304 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1305 let id_str = resolve_id(&conn, &id_str)?;
1306
1307 let field_def =
1308 schema
1309 .fields
1310 .iter()
1311 .find(|f| f.name == field)
1312 .ok_or_else(|| {
1313 MiniAppError::Schema(format!("field `{field}` not in schema"))
1314 })?;
1315 if field_def.ty != FieldType::String {
1316 return Err(MiniAppError::FieldTypeError {
1317 field: field.clone(),
1318 actual_type: field_def.ty.as_str().to_string(),
1319 });
1320 }
1321
1322 if old_str.is_empty() {
1324 return Err(MiniAppError::Validation {
1325 field: field.clone(),
1326 reason: "old_str must not be empty".to_string(),
1327 });
1328 }
1329
1330 let row_data: Option<(String, i64)> = conn
1331 .query_row(
1332 "SELECT data, created_at FROM rows WHERE id = ?1",
1333 rusqlite::params![id_str],
1334 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
1335 )
1336 .optional()?;
1337 let (data_str, created_at) =
1338 row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
1339
1340 let mut data = parse_data(&data_str)?;
1341 let text = data
1342 .get(&field)
1343 .and_then(|v| v.as_str())
1344 .unwrap_or("")
1345 .to_string();
1346
1347 if let Some((s, e)) = view_range {
1349 if s == 0 || s > e {
1350 return Err(MiniAppError::Validation {
1351 field: field.clone(),
1352 reason: format!(
1353 "view_range [{s}, {e}] is invalid: start must be ≥ 1 and ≤ end"
1354 ),
1355 });
1356 }
1357 }
1358
1359 let search_scope = if let Some((s, e)) = view_range {
1361 let lines: Vec<&str> = text.split('\n').collect();
1362 let total = lines.len() as u32;
1363 let si = (s.max(1).saturating_sub(1)) as usize;
1364 let ei = (e.min(total) as usize).min(lines.len());
1365 lines[si..ei].join("\n")
1366 } else {
1367 text.clone()
1368 };
1369
1370 const CAND_CAP: usize = 20;
1376 let mut candidates: Vec<crate::error::MatchCandidate> = Vec::new();
1377 let mut match_count = 0u32;
1378 let mut pos = 0usize;
1379 let old_len = old_str.len().max(1);
1380 while let Some(rel) = search_scope[pos..].find(old_str.as_str()) {
1381 let abs = pos + rel;
1382 match_count += 1;
1383 if candidates.len() < CAND_CAP {
1384 let before = &search_scope[..abs];
1385 let line_no = (before.matches('\n').count() as u32) + 1;
1386 let col = (abs - before.rfind('\n').map(|p| p + 1).unwrap_or(0)) as u32 + 1;
1387 let mut snip_start = abs.saturating_sub(30);
1388 while snip_start < abs && !search_scope.is_char_boundary(snip_start) {
1389 snip_start += 1;
1390 }
1391 let mut snip_end = (abs + old_str.len() + 30).min(search_scope.len());
1392 while snip_end > abs + old_str.len()
1393 && !search_scope.is_char_boundary(snip_end)
1394 {
1395 snip_end -= 1;
1396 }
1397 candidates.push(crate::error::MatchCandidate {
1398 line: line_no,
1399 col,
1400 snippet: search_scope[snip_start..snip_end].to_string(),
1401 });
1402 }
1403 pos = abs + old_len;
1404 }
1405 if match_count == 0 {
1406 return Err(MiniAppError::StringNotFound {
1407 field: field.clone(),
1408 });
1409 }
1410 if !replace_all && match_count >= 2 {
1411 return Err(MiniAppError::AmbiguousMatch {
1412 field: field.clone(),
1413 matches: match_count,
1414 candidates,
1415 });
1416 }
1417
1418 let new_text = if let Some((s, e)) = view_range {
1420 let lines: Vec<String> = text.split('\n').map(str::to_string).collect();
1421 let total = lines.len() as u32;
1422 let si = (s.max(1).saturating_sub(1)) as usize;
1423 let ei = (e.min(total) as usize).min(lines.len());
1424 let scoped = lines[si..ei].join("\n");
1425 let replaced_scoped = if replace_all {
1426 scoped.replace(old_str.as_str(), new_str.as_str())
1427 } else {
1428 scoped.replacen(old_str.as_str(), new_str.as_str(), 1)
1429 };
1430 let mut all_lines: Vec<String> = lines[..si].to_vec();
1431 all_lines.extend(replaced_scoped.split('\n').map(str::to_string));
1432 all_lines.extend_from_slice(&lines[ei..]);
1433 all_lines.join("\n")
1434 } else if replace_all {
1435 text.replace(old_str.as_str(), new_str.as_str())
1436 } else {
1437 text.replacen(old_str.as_str(), new_str.as_str(), 1)
1438 };
1439
1440 data.as_object_mut()
1442 .expect("data is always a JSON object")
1443 .insert(field.clone(), serde_json::Value::String(new_text));
1444 schema.validate(&data)?;
1445
1446 let new_data_str =
1447 serde_json::to_string(&data).expect("serialization is infallible");
1448
1449 let tx = conn.transaction()?;
1451 tx.execute(
1452 "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
1453 rusqlite::params![new_data_str, now, id_str],
1454 )?;
1455 crate::row_history::record_in_tx(
1456 &tx,
1457 &table_name,
1458 &id_str,
1459 crate::row_history::HistoryOp::Update,
1460 Some(&new_data_str),
1461 Some(&data_str),
1462 now,
1463 )?;
1464 tx.commit()?;
1465
1466 Ok((
1467 RowRecord {
1468 id: id_str,
1469 data,
1470 created_at,
1471 updated_at: now,
1472 },
1473 match_count,
1474 ))
1475 })
1476 .await
1477 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
1478
1479 crate::dump::on_change(&self.schema, &record).await?;
1480 Ok(ReplaceResult { matches })
1481 }
1482
1483 pub async fn insert_into_string_field(
1496 &self,
1497 id: &str,
1498 field: &str,
1499 line: u32,
1500 content: &str,
1501 ) -> Result<(), MiniAppError> {
1502 let now = now_secs();
1503 let conn = self.conn.clone();
1504 let id_str = id.to_string();
1505 let field = field.to_string();
1506 let content = content.to_string();
1507 let schema = self.schema.clone();
1508 let table_name = self.schema.table.clone();
1509
1510 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
1511 let mut conn = conn
1512 .lock()
1513 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1514 let id_str = resolve_id(&conn, &id_str)?;
1515
1516 let field_def = schema
1517 .fields
1518 .iter()
1519 .find(|f| f.name == field)
1520 .ok_or_else(|| MiniAppError::Schema(format!("field `{field}` not in schema")))?;
1521 if field_def.ty != FieldType::String {
1522 return Err(MiniAppError::FieldTypeError {
1523 field: field.clone(),
1524 actual_type: field_def.ty.as_str().to_string(),
1525 });
1526 }
1527
1528 let row_data: Option<(String, i64)> = conn
1529 .query_row(
1530 "SELECT data, created_at FROM rows WHERE id = ?1",
1531 rusqlite::params![id_str],
1532 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
1533 )
1534 .optional()?;
1535 let (data_str, created_at) =
1536 row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
1537
1538 let mut data = parse_data(&data_str)?;
1539 let text = data
1540 .get(&field)
1541 .and_then(|v| v.as_str())
1542 .unwrap_or("")
1543 .to_string();
1544
1545 let mut lines: Vec<String> = text.split('\n').map(str::to_string).collect();
1546 let total = lines.len() as u32;
1547
1548 if line > total {
1550 return Err(MiniAppError::LineOutOfRange {
1551 line,
1552 total_lines: total,
1553 });
1554 }
1555 lines.insert(line as usize, content.clone());
1557
1558 let new_text = lines.join("\n");
1559 data.as_object_mut()
1560 .expect("data is always a JSON object")
1561 .insert(field.clone(), serde_json::Value::String(new_text));
1562 schema.validate(&data)?;
1563
1564 let new_data_str = serde_json::to_string(&data).expect("serialization is infallible");
1565
1566 let tx = conn.transaction()?;
1567 tx.execute(
1568 "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
1569 rusqlite::params![new_data_str, now, id_str],
1570 )?;
1571 crate::row_history::record_in_tx(
1572 &tx,
1573 &table_name,
1574 &id_str,
1575 crate::row_history::HistoryOp::Update,
1576 Some(&new_data_str),
1577 Some(&data_str),
1578 now,
1579 )?;
1580 tx.commit()?;
1581
1582 Ok(RowRecord {
1583 id: id_str,
1584 data,
1585 created_at,
1586 updated_at: now,
1587 })
1588 })
1589 .await
1590 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
1591
1592 crate::dump::on_change(&self.schema, &record).await?;
1593 Ok(())
1594 }
1595}
1596
1597#[cfg(test)]
1602mod tests {
1603 use std::sync::Arc;
1604
1605 use super::*;
1606 use crate::schema::{FieldDef, FieldType};
1607
1608 async fn make_test_store() -> Store {
1609 let schema = SchemaConfig {
1610 table: "test".into(),
1611 title: None,
1612 description: None,
1613 fields: vec![
1614 FieldDef {
1615 name: "title".into(),
1616 ty: FieldType::String,
1617 required: true,
1618 description: None,
1619 },
1620 FieldDef {
1621 name: "state".into(),
1622 ty: FieldType::String,
1623 required: false,
1624 description: None,
1625 },
1626 ],
1627 dump: None,
1628 };
1629 Store::open(Path::new(":memory:"), schema).await.unwrap()
1630 }
1631
1632 async fn make_test_store_with_dump(dir: &Path) -> Store {
1634 use crate::dump::{DumpConfig, SyncMode};
1635 let schema = SchemaConfig {
1636 table: "test".into(),
1637 title: None,
1638 description: None,
1639 fields: vec![
1640 FieldDef {
1641 name: "title".into(),
1642 ty: FieldType::String,
1643 required: true,
1644 description: None,
1645 },
1646 FieldDef {
1647 name: "body".into(),
1648 ty: FieldType::String,
1649 required: false,
1650 description: None,
1651 },
1652 ],
1653 dump: Some(DumpConfig {
1654 dir: Some(dir.to_path_buf()),
1655 title_field: None,
1656 body_field: None,
1657 sync: Some(SyncMode::WriteOnly),
1658 }),
1659 };
1660 Store::open(Path::new(":memory:"), schema).await.unwrap()
1661 }
1662
1663 #[tokio::test]
1666 async fn test_create_and_get_roundtrip() {
1667 let store = make_test_store().await;
1668 let value = serde_json::json!({"title": "hello", "state": "open"});
1669 let row = store.create(value.clone()).await.unwrap();
1670 let fetched = store.get(&row.id).await.unwrap();
1671 assert_eq!(fetched.id, row.id);
1672 assert_eq!(fetched.data, value);
1673 }
1674
1675 #[tokio::test]
1676 async fn test_create_then_list() {
1677 let store = make_test_store().await;
1678 store
1679 .create(serde_json::json!({"title": "t1"}))
1680 .await
1681 .unwrap();
1682 let rows = store.list(None, None, None, None).await.unwrap();
1683 assert_eq!(rows.len(), 1);
1684 }
1685
1686 #[tokio::test]
1687 async fn test_list_limit_offset() {
1688 let store = make_test_store().await;
1689 for i in 0..5 {
1690 store
1691 .create(serde_json::json!({"title": format!("item-{i}")}))
1692 .await
1693 .unwrap();
1694 }
1695 let page1 = store.list(Some(2), Some(0), None, None).await.unwrap();
1696 assert_eq!(page1.len(), 2);
1697 let page2 = store.list(Some(2), Some(2), None, None).await.unwrap();
1698 assert_eq!(page2.len(), 2);
1699 let page3 = store.list(Some(2), Some(4), None, None).await.unwrap();
1700 assert_eq!(page3.len(), 1);
1701 }
1702
1703 #[tokio::test]
1704 async fn test_update_timestamps() {
1705 let store = make_test_store().await;
1706 let row = store
1707 .create(serde_json::json!({"title": "original"}))
1708 .await
1709 .unwrap();
1710 let updated = store
1714 .update(
1715 &row.id,
1716 serde_json::json!({"title": "changed"}),
1717 UpdateMode::Replace,
1718 )
1719 .await
1720 .unwrap();
1721 assert_eq!(updated.created_at, row.created_at);
1722 assert_eq!(updated.id, row.id);
1723 assert_eq!(updated.data["title"], "changed");
1724 }
1725
1726 #[tokio::test]
1727 async fn test_create_delete_get_not_found() {
1728 let store = make_test_store().await;
1729 let row = store
1730 .create(serde_json::json!({"title": "to-delete"}))
1731 .await
1732 .unwrap();
1733 store.delete(&row.id).await.unwrap();
1734 let err = store.get(&row.id).await.unwrap_err();
1735 assert!(matches!(err, MiniAppError::NotFound { .. }));
1736 }
1737
1738 #[tokio::test]
1739 async fn test_get_unknown_id_not_found() {
1740 let store = make_test_store().await;
1741 let err = store.get("nonexistent-id").await.unwrap_err();
1742 assert!(matches!(err, MiniAppError::NotFound { .. }));
1743 }
1744
1745 #[tokio::test]
1746 async fn test_update_unknown_id_not_found() {
1747 let store = make_test_store().await;
1748 let err = store
1749 .update(
1750 "nonexistent-id",
1751 serde_json::json!({"title": "x"}),
1752 UpdateMode::Replace,
1753 )
1754 .await
1755 .unwrap_err();
1756 assert!(matches!(err, MiniAppError::NotFound { .. }));
1757 }
1758
1759 #[tokio::test]
1760 async fn test_delete_unknown_id_not_found() {
1761 let store = make_test_store().await;
1762 let err = store.delete("nonexistent-id").await.unwrap_err();
1763 assert!(matches!(err, MiniAppError::NotFound { .. }));
1764 }
1765
1766 #[tokio::test]
1767 async fn test_create_missing_required_field_validation_error() {
1768 let store = make_test_store().await;
1769 let err = store
1771 .create(serde_json::json!({"state": "open"}))
1772 .await
1773 .unwrap_err();
1774 assert!(
1775 matches!(err, MiniAppError::Validation { .. }),
1776 "expected Validation, got: {err:?}"
1777 );
1778 }
1779
1780 #[tokio::test]
1781 async fn test_create_type_mismatch_validation_error() {
1782 let store = make_test_store().await;
1783 let err = store
1785 .create(serde_json::json!({"title": 42}))
1786 .await
1787 .unwrap_err();
1788 assert!(
1789 matches!(err, MiniAppError::Validation { .. }),
1790 "expected Validation, got: {err:?}"
1791 );
1792 }
1793
1794 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1797 async fn test_store_create_concurrent() {
1798 let store = Arc::new(make_test_store().await);
1799 let handles: Vec<_> = (0..4)
1800 .map(|i| {
1801 let s = store.clone();
1802 tokio::spawn(async move {
1803 s.create(serde_json::json!({"title": format!("task-{i}"), "state": "open"}))
1804 .await
1805 })
1806 })
1807 .collect();
1808 let results: Vec<_> = futures::future::join_all(handles).await;
1809 assert!(results.iter().all(|r| r.as_ref().unwrap().is_ok()));
1810 let rows = store.list(None, None, None, None).await.unwrap();
1811 assert_eq!(rows.len(), 4);
1812 }
1813
1814 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1815 async fn test_store_mutex_no_await_holding_lock() {
1816 let store = Arc::new(make_test_store().await);
1817 let id = store
1818 .create(serde_json::json!({"title": "init", "state": "open"}))
1819 .await
1820 .unwrap()
1821 .id;
1822 let s1 = store.clone();
1823 let id1 = id.clone();
1824 let h1 = tokio::spawn(async move { s1.get(&id1).await });
1825 let s2 = store.clone();
1826 let id2 = id.clone();
1827 let h2 = tokio::spawn(async move {
1828 s2.update(
1829 &id2,
1830 serde_json::json!({"title": "updated", "state": "closed"}),
1831 UpdateMode::Replace,
1832 )
1833 .await
1834 });
1835 let (r1, r2) = tokio::join!(h1, h2);
1836 assert!(r1.unwrap().is_ok());
1837 assert!(r2.unwrap().is_ok());
1838 }
1839
1840 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1841 async fn test_store_arc_clone_across_tasks() {
1842 let store = Arc::new(make_test_store().await);
1843 let handles: Vec<_> = (0..8)
1844 .map(|i| {
1845 let s = Arc::clone(&store);
1846 tokio::spawn(async move {
1847 s.create(serde_json::json!({"title": format!("row-{i}"), "state": "open"}))
1848 .await
1849 })
1850 })
1851 .collect();
1852 futures::future::join_all(handles).await;
1853 assert_eq!(store.list(None, None, None, None).await.unwrap().len(), 8);
1854 }
1855
1856 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1857 async fn test_spawn_blocking_join_error_propagation() {
1858 let result: Result<(), _> = tokio::task::spawn_blocking(|| panic!("intentional"))
1859 .await
1860 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")));
1861 assert!(matches!(result, Err(MiniAppError::Schema(_))));
1862 }
1863
1864 #[tokio::test]
1867 async fn create_triggers_dump_when_configured() {
1868 let tmp = tempfile::tempdir().expect("tempdir");
1869 let store = make_test_store_with_dump(tmp.path()).await;
1870 let row = store
1871 .create(serde_json::json!({"title": "My Issue", "body": "Details"}))
1872 .await
1873 .expect("create ok");
1874 let dump_file = tmp.path().join(format!("{}.md", row.id));
1875 assert!(dump_file.exists(), "dump file must be created after create");
1876 let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1877 assert!(content.starts_with("# My Issue\n"));
1878 assert!(content.contains("Details"));
1879 }
1880
1881 #[tokio::test]
1882 async fn update_overwrites_dump_file() {
1883 let tmp = tempfile::tempdir().expect("tempdir");
1884 let store = make_test_store_with_dump(tmp.path()).await;
1885 let row = store
1886 .create(serde_json::json!({"title": "Original", "body": "v1"}))
1887 .await
1888 .expect("create ok");
1889
1890 store
1891 .update(
1892 &row.id,
1893 serde_json::json!({"title": "Updated", "body": "v2"}),
1894 UpdateMode::Replace,
1895 )
1896 .await
1897 .expect("update ok");
1898
1899 let dump_file = tmp.path().join(format!("{}.md", row.id));
1900 let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1901 assert!(
1902 content.starts_with("# Updated\n"),
1903 "dump file must reflect updated title"
1904 );
1905 assert!(
1906 content.contains("v2"),
1907 "dump file must reflect updated body"
1908 );
1909 }
1910
1911 #[tokio::test]
1912 async fn delete_keeps_dump_file_by_default() {
1913 let tmp = tempfile::tempdir().expect("tempdir");
1914 let store = make_test_store_with_dump(tmp.path()).await;
1915 let row = store
1916 .create(serde_json::json!({"title": "Keep Me", "body": ""}))
1917 .await
1918 .expect("create ok");
1919
1920 let dump_file = tmp.path().join(format!("{}.md", row.id));
1921 assert!(dump_file.exists(), "dump file must exist after create");
1922
1923 store.delete(&row.id).await.expect("delete ok");
1924 assert!(
1925 dump_file.exists(),
1926 "dump file must remain after delete (default: keep)"
1927 );
1928 }
1929
1930 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1931 async fn test_store_create_concurrent_dump_writes_all_files() {
1932 let tmp = tempfile::tempdir().expect("tempdir");
1933 let store = Arc::new(make_test_store_with_dump(tmp.path()).await);
1934
1935 let handles: Vec<_> = (0..4)
1936 .map(|i| {
1937 let s = store.clone();
1938 tokio::spawn(async move {
1939 s.create(serde_json::json!({
1940 "title": format!("concurrent-{i}"),
1941 "body": format!("body-{i}"),
1942 }))
1943 .await
1944 })
1945 })
1946 .collect();
1947
1948 let results: Vec<_> = futures::future::join_all(handles).await;
1949 let rows: Vec<_> = results
1951 .into_iter()
1952 .map(|r| r.expect("spawn ok").expect("create ok"))
1953 .collect();
1954
1955 for row in &rows {
1957 let path = tmp.path().join(format!("{}.md", row.id));
1958 assert!(path.exists(), "dump file must exist for row {}", row.id);
1959 }
1960 assert_eq!(rows.len(), 4);
1961 }
1962
1963 #[tokio::test]
1964 async fn store_open_with_bidirectional_sync_returns_ok() {
1965 use crate::dump::{DumpConfig, SyncMode};
1966 let schema = SchemaConfig {
1969 table: "test".into(),
1970 title: None,
1971 description: None,
1972 fields: vec![FieldDef {
1973 name: "title".into(),
1974 ty: FieldType::String,
1975 required: false,
1976 description: None,
1977 }],
1978 dump: Some(DumpConfig {
1979 dir: None,
1980 title_field: None,
1981 body_field: None,
1982 sync: Some(SyncMode::Bidirectional),
1983 }),
1984 };
1985 let store = Store::open(Path::new(":memory:"), schema).await;
1986 assert!(
1987 store.is_ok(),
1988 "Store::open must succeed even with bidirectional sync configured"
1989 );
1990 }
1991
1992 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2000 async fn test_savepoint_atomic_rollback_on_op_failure() {
2001 let store = make_test_store().await;
2002
2003 let result: Result<(), MiniAppError> = store
2005 .execute_under_savepoint(|sp| {
2006 sp.execute(
2007 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
2008 rusqlite::params!["sp-test-id", r#"{"title":"t"}"#, 1000_i64, 1000_i64],
2009 )?;
2010 Err(MiniAppError::Validation {
2012 field: "test".into(),
2013 reason: "forced rollback".into(),
2014 })
2015 })
2016 .await;
2017
2018 assert!(
2019 result.is_err(),
2020 "execute_under_savepoint must propagate the closure error"
2021 );
2022 assert!(
2023 matches!(result.unwrap_err(), MiniAppError::Validation { .. }),
2024 "error variant must be preserved"
2025 );
2026
2027 let rows = store.list(Some(1000), None, None, None).await.unwrap();
2029 assert_eq!(
2030 rows.len(),
2031 0,
2032 "SAVEPOINT rollback must revert the INSERT (Crux: SAVEPOINT atomicity)"
2033 );
2034
2035 store
2037 .create(serde_json::json!({"title": "after-rollback"}))
2038 .await
2039 .expect("store must be usable after SAVEPOINT rollback");
2040 assert_eq!(store.list(None, None, None, None).await.unwrap().len(), 1);
2041 }
2042
2043 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2045 async fn test_savepoint_commit_on_success() {
2046 let store = make_test_store().await;
2047
2048 let result = store
2049 .execute_under_savepoint(|sp| {
2050 sp.execute(
2051 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
2052 rusqlite::params!["sp-ok-id", r#"{"title":"committed"}"#, 1000_i64, 1000_i64],
2053 )?;
2054 Ok(42_u32)
2055 })
2056 .await;
2057
2058 assert_eq!(
2059 result.unwrap(),
2060 42_u32,
2061 "successful SAVEPOINT must return value"
2062 );
2063
2064 let rows = store.list(Some(10), None, None, None).await.unwrap();
2066 assert_eq!(rows.len(), 1, "committed INSERT must persist");
2067 }
2068
2069 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2072 async fn test_store_concurrent_create() {
2073 let store = Arc::new(make_test_store().await);
2074 let task_count = 8_usize;
2075 let rows_per_task = 100_usize;
2076
2077 let handles: Vec<_> = (0..task_count)
2078 .map(|task_id| {
2079 let s = Arc::clone(&store);
2080 tokio::spawn(async move {
2081 for i in 0..rows_per_task {
2082 s.create(serde_json::json!({"title": format!("task-{task_id}-row-{i}")}))
2083 .await
2084 .expect("concurrent create must succeed");
2085 }
2086 })
2087 })
2088 .collect();
2089
2090 futures::future::join_all(handles)
2091 .await
2092 .into_iter()
2093 .for_each(|r| r.expect("task must not panic"));
2094
2095 let total = store
2096 .list(Some(1000), None, None, None)
2097 .await
2098 .unwrap()
2099 .len();
2100 assert_eq!(
2101 total,
2102 task_count * rows_per_task,
2103 "all {total} rows must be present; expected {}",
2104 task_count * rows_per_task
2105 );
2106 }
2107
2108 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2111 async fn test_store_concurrent_update_same_id() {
2112 let store = Arc::new(make_test_store().await);
2113
2114 let row = store
2116 .create(serde_json::json!({"title": "initial"}))
2117 .await
2118 .unwrap();
2119 let id = row.id.clone();
2120
2121 let task_count = 4_usize;
2122 let updates_per_task = 50_usize;
2123
2124 let handles: Vec<_> = (0..task_count)
2125 .map(|task_id| {
2126 let s = Arc::clone(&store);
2127 let row_id = id.clone();
2128 tokio::spawn(async move {
2129 for i in 0..updates_per_task {
2130 s.update(
2131 &row_id,
2132 serde_json::json!({"title": format!("task-{task_id}-update-{i}")}),
2133 UpdateMode::Replace,
2134 )
2135 .await
2136 .expect("concurrent update must succeed");
2137 }
2138 })
2139 })
2140 .collect();
2141
2142 futures::future::join_all(handles)
2143 .await
2144 .into_iter()
2145 .for_each(|r| r.expect("task must not panic"));
2146
2147 let rows = store.list(None, None, None, None).await.unwrap();
2149 assert_eq!(rows.len(), 1, "update must not insert extra rows");
2150 assert!(
2151 rows[0].data["title"].is_string(),
2152 "title must be a string after concurrent updates"
2153 );
2154 }
2155
2156 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2161 async fn test_store_mutex_poison_propagated_as_error() {
2162 let store = Arc::new(make_test_store().await);
2163
2164 let conn = store.conn.clone();
2166 let _ = tokio::task::spawn_blocking(move || {
2167 let _guard = conn.lock().unwrap(); panic!("intentional poison"); })
2170 .await; let err = store.get("any-id").await.unwrap_err();
2174 assert!(
2175 matches!(&err, MiniAppError::Schema(msg) if msg.contains("mutex poisoned")),
2176 "expected Schema(\"mutex poisoned\"), got: {err:?}"
2177 );
2178 }
2179
2180 #[tokio::test]
2184 async fn store_open_sets_wal_journal_mode() {
2185 let tmp = tempfile::tempdir().expect("tempdir");
2186 let db_path = tmp.path().join("test.db");
2187
2188 let schema = SchemaConfig {
2189 table: "test".into(),
2190 title: None,
2191 description: None,
2192 fields: vec![FieldDef {
2193 name: "title".into(),
2194 ty: FieldType::String,
2195 required: false,
2196 description: None,
2197 }],
2198 dump: None,
2199 };
2200 let store = Store::open(&db_path, schema)
2201 .await
2202 .expect("Store::open should succeed");
2203
2204 let mode = {
2206 let conn = store.conn.lock().expect("lock");
2207 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
2208 .expect("PRAGMA journal_mode query")
2209 };
2210 assert_eq!(
2211 mode.to_lowercase(),
2212 "wal",
2213 "Store::open must set journal_mode = WAL for dual-registry safety (Crux #1)"
2214 );
2215 }
2216
2217 fn make_schema(fields: Vec<FieldDef>) -> SchemaConfig {
2221 SchemaConfig {
2222 table: "test".into(),
2223 title: None,
2224 description: None,
2225 fields,
2226 dump: None,
2227 }
2228 }
2229
2230 #[test]
2232 fn shallow_merge_preserves_absent_fields() {
2233 let schema = make_schema(vec![
2234 FieldDef {
2235 name: "a".into(),
2236 ty: FieldType::Number,
2237 required: true,
2238 description: None,
2239 },
2240 FieldDef {
2241 name: "b".into(),
2242 ty: FieldType::Number,
2243 required: false,
2244 description: None,
2245 },
2246 ]);
2247 let current = serde_json::json!({"a": 1, "b": 2});
2248 let patch = serde_json::json!({"a": 9});
2249 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
2250 assert_eq!(merged["a"], 9, "patched field must be updated");
2251 assert_eq!(
2252 merged["b"], 2,
2253 "absent patch field must be preserved from current"
2254 );
2255 }
2256
2257 #[test]
2259 fn shallow_merge_deletes_null_for_optional_field() {
2260 let schema = make_schema(vec![
2261 FieldDef {
2262 name: "a".into(),
2263 ty: FieldType::Number,
2264 required: true,
2265 description: None,
2266 },
2267 FieldDef {
2268 name: "b".into(),
2269 ty: FieldType::Number,
2270 required: false,
2271 description: None,
2272 },
2273 ]);
2274 let current = serde_json::json!({"a": 1, "b": 2});
2275 let patch = serde_json::json!({"b": null});
2276 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
2277 assert_eq!(merged["a"], 1);
2278 assert!(
2279 merged.get("b").is_none(),
2280 "null-patched optional field must be physically removed (not set to null)"
2281 );
2282 }
2283
2284 #[test]
2286 fn shallow_merge_errors_on_null_for_required_field() {
2287 let schema = make_schema(vec![FieldDef {
2288 name: "title".into(),
2289 ty: FieldType::String,
2290 required: true,
2291 description: None,
2292 }]);
2293 let current = serde_json::json!({"title": "hello"});
2294 let patch = serde_json::json!({"title": null});
2295 let err = shallow_merge(current, patch, &schema).expect_err("must error");
2296 match err {
2297 MiniAppError::Validation { field, reason } => {
2298 assert_eq!(field, "title");
2299 assert!(
2300 reason.contains("required field cannot be deleted via null"),
2301 "unexpected reason: {reason}"
2302 );
2303 }
2304 other => panic!("expected Validation error, got: {other:?}"),
2305 }
2306 }
2307
2308 #[test]
2310 fn shallow_merge_replaces_nested_object_wholesale() {
2311 let schema = make_schema(vec![FieldDef {
2312 name: "cfg".into(),
2313 ty: FieldType::Object,
2314 required: false,
2315 description: None,
2316 }]);
2317 let current = serde_json::json!({"cfg": {"x": 1, "y": 2}});
2318 let patch = serde_json::json!({"cfg": {"x": 9}});
2319 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
2320 assert_eq!(merged["cfg"]["x"], 9, "x must be updated");
2321 assert!(
2322 merged["cfg"].get("y").is_none(),
2323 "y must be absent (nested object replaced wholesale, not deep-merged)"
2324 );
2325 }
2326
2327 #[test]
2329 fn shallow_merge_rejects_non_object_patch() {
2330 let schema = make_schema(vec![]);
2331 let current = serde_json::json!({"a": 1});
2332
2333 for bad_patch in [
2334 serde_json::json!([1, 2, 3]),
2335 serde_json::json!(42),
2336 serde_json::json!("string"),
2337 ] {
2338 let err = shallow_merge(current.clone(), bad_patch, &schema)
2339 .expect_err("non-object patch must be rejected");
2340 match err {
2341 MiniAppError::Validation { field, .. } => {
2342 assert_eq!(field, "(root)", "error field must be '(root)'");
2343 }
2344 other => panic!("expected Validation error, got: {other:?}"),
2345 }
2346 }
2347 }
2348
2349 #[tokio::test]
2352 async fn store_update_merge_runs_post_merge_validation() {
2353 let store = make_test_store().await;
2354 let row = store
2355 .create(serde_json::json!({"title": "x", "state": "open"}))
2356 .await
2357 .unwrap();
2358
2359 let err = store
2361 .update(&row.id, serde_json::json!({"state": 42}), UpdateMode::Merge)
2362 .await
2363 .expect_err("type mismatch must fail post-merge validation");
2364
2365 assert!(
2366 matches!(err, MiniAppError::Validation { .. }),
2367 "expected Validation error, got: {err:?}"
2368 );
2369 }
2370
2371 use crate::filter::ListFilter;
2376
2377 fn make_filter() -> ListFilter {
2379 ListFilter::Eq {
2380 field: "state".to_string(),
2381 value: serde_json::json!("open"),
2382 }
2383 }
2384
2385 #[tokio::test]
2387 async fn alias_create_and_get_round_trip() {
2388 let store = make_test_store().await;
2389 let filter = make_filter();
2390 let filter_json = serde_json::to_string(&filter).unwrap();
2391
2392 store
2393 .alias_create(
2394 "recent_open",
2395 &filter_json,
2396 Some(20),
2397 Some("desc".to_string()),
2398 None,
2399 )
2400 .await
2401 .expect("alias_create must succeed");
2402
2403 let record = store
2404 .alias_get("recent_open")
2405 .await
2406 .expect("alias_get must succeed");
2407
2408 assert_eq!(record.name, "recent_open");
2409 assert_eq!(record.default_limit, Some(20));
2410 assert_eq!(record.description.as_deref(), Some("desc"));
2411
2412 let restored: ListFilter =
2414 serde_json::from_str(&record.filter).expect("filter must deserialise");
2415 let stored_back = serde_json::to_string(&filter).unwrap();
2416 let stored_back2 = serde_json::to_string(&restored).unwrap();
2417 assert_eq!(
2418 stored_back, stored_back2,
2419 "filter must survive a JSON round-trip"
2420 );
2421 }
2422
2423 #[tokio::test]
2425 async fn alias_create_with_optional_nulls() {
2426 let store = make_test_store().await;
2427 let filter = make_filter();
2428 let filter_json = serde_json::to_string(&filter).unwrap();
2429
2430 store
2431 .alias_create("no_opts", &filter_json, None, None, None)
2432 .await
2433 .expect("alias_create must succeed with None optionals");
2434
2435 let record = store
2436 .alias_get("no_opts")
2437 .await
2438 .expect("alias_get must succeed");
2439 assert_eq!(record.name, "no_opts");
2440 assert!(record.default_limit.is_none());
2441 assert!(record.description.is_none());
2442 }
2443
2444 #[tokio::test]
2446 async fn alias_list_returns_all() {
2447 let store = make_test_store().await;
2448 let filter = make_filter();
2449
2450 let list = store
2452 .alias_list()
2453 .await
2454 .expect("alias_list must succeed on empty store");
2455 assert!(list.is_empty(), "empty store should return empty list");
2456
2457 let filter_json = serde_json::to_string(&filter).unwrap();
2458 store
2459 .alias_create("b_alias", &filter_json, None, None, None)
2460 .await
2461 .unwrap();
2462 store
2463 .alias_create("a_alias", &filter_json, None, None, None)
2464 .await
2465 .unwrap();
2466
2467 let list = store.alias_list().await.expect("alias_list must succeed");
2468 assert_eq!(list.len(), 2, "must return 2 aliases");
2469 assert_eq!(list[0].name, "a_alias");
2471 assert_eq!(list[1].name, "b_alias");
2472 }
2473
2474 #[tokio::test]
2476 async fn alias_delete_removes_alias() {
2477 let store = make_test_store().await;
2478 let filter = make_filter();
2479 let filter_json = serde_json::to_string(&filter).unwrap();
2480
2481 store
2482 .alias_create("to_delete", &filter_json, None, None, None)
2483 .await
2484 .unwrap();
2485
2486 store
2487 .alias_delete("to_delete")
2488 .await
2489 .expect("alias_delete must succeed");
2490
2491 let err = store
2492 .alias_get("to_delete")
2493 .await
2494 .expect_err("alias_get after delete must fail");
2495
2496 assert!(
2497 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "to_delete"),
2498 "expected AliasNotFound, got: {err:?}"
2499 );
2500 }
2501
2502 #[tokio::test]
2504 async fn alias_create_duplicate_returns_already_exists() {
2505 let store = make_test_store().await;
2506 let filter = make_filter();
2507 let filter_json = serde_json::to_string(&filter).unwrap();
2508
2509 store
2510 .alias_create("dup", &filter_json, None, None, None)
2511 .await
2512 .expect("first alias_create must succeed");
2513
2514 let err = store
2515 .alias_create("dup", &filter_json, None, None, None)
2516 .await
2517 .expect_err("second alias_create must fail");
2518
2519 assert!(
2520 matches!(err, MiniAppError::AliasAlreadyExists { ref name } if name == "dup"),
2521 "expected AliasAlreadyExists, got: {err:?}"
2522 );
2523 }
2524
2525 #[tokio::test]
2527 async fn alias_get_missing_returns_not_found() {
2528 let store = make_test_store().await;
2529
2530 let err = store
2531 .alias_get("nonexistent")
2532 .await
2533 .expect_err("alias_get on missing alias must fail");
2534
2535 assert!(
2536 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
2537 "expected AliasNotFound, got: {err:?}"
2538 );
2539 }
2540
2541 #[tokio::test]
2543 async fn alias_delete_missing_returns_not_found() {
2544 let store = make_test_store().await;
2545
2546 let err = store
2547 .alias_delete("nonexistent")
2548 .await
2549 .expect_err("alias_delete on missing alias must fail");
2550
2551 assert!(
2552 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
2553 "expected AliasNotFound, got: {err:?}"
2554 );
2555 }
2556
2557 #[tokio::test]
2560 async fn alias_namespace_isolation_between_stores() {
2561 let store_a = make_test_store().await;
2562 let store_b = make_test_store().await;
2563 let filter = make_filter();
2564
2565 let filter_json = serde_json::to_string(&filter).unwrap();
2566 store_a
2567 .alias_create("shared_name", &filter_json, None, None, None)
2568 .await
2569 .expect("store_a alias_create must succeed");
2570
2571 let err = store_b
2573 .alias_get("shared_name")
2574 .await
2575 .expect_err("alias created in store_a must not be visible in store_b");
2576
2577 assert!(
2578 matches!(err, MiniAppError::AliasNotFound { .. }),
2579 "expected AliasNotFound in store_b, got: {err:?}"
2580 );
2581 }
2582
2583 #[tokio::test]
2587 async fn test_get_prefix_match_single() {
2588 let store = make_test_store().await;
2589 let row = store
2590 .create(serde_json::json!({"title": "prefix-test"}))
2591 .await
2592 .unwrap();
2593 let prefix = &row.id[..8];
2595 let fetched = store.get(prefix).await.unwrap();
2596 assert_eq!(fetched.id, row.id);
2597 assert_eq!(fetched.data["title"], "prefix-test");
2598 }
2599
2600 #[tokio::test]
2602 async fn test_get_prefix_match_not_found() {
2603 let store = make_test_store().await;
2604 let err = store.get("zzzzzzzz").await.unwrap_err();
2606 assert!(
2607 matches!(err, MiniAppError::NotFound { .. }),
2608 "expected NotFound, got: {err:?}"
2609 );
2610 }
2611
2612 #[tokio::test]
2614 async fn test_get_prefix_match_ambiguous() {
2615 let store = make_test_store().await;
2616 let id1 = "aaaaaaaa-0000-4000-8000-000000000001".to_string();
2619 let id2 = "aaaaaaaa-0000-4000-8000-000000000002".to_string();
2620 let id1_clone = id1.clone();
2621 let id2_clone = id2.clone();
2622 store
2623 .execute_under_savepoint(move |sp| {
2624 sp.execute(
2625 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2626 rusqlite::params![id1_clone, r#"{"title":"a1"}"#],
2627 )?;
2628 sp.execute(
2629 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2630 rusqlite::params![id2_clone, r#"{"title":"a2"}"#],
2631 )?;
2632 Ok(())
2633 })
2634 .await
2635 .unwrap();
2636
2637 let err = store.get("aaaaaaaa").await.unwrap_err();
2638 match err {
2639 MiniAppError::AmbiguousId {
2640 ref id_prefix,
2641 ref candidates,
2642 } => {
2643 assert_eq!(id_prefix, "aaaaaaaa");
2644 assert_eq!(candidates.len(), 2);
2645 let mut sorted = candidates.clone();
2646 sorted.sort();
2647 assert_eq!(sorted[0], id1);
2648 assert_eq!(sorted[1], id2);
2649 }
2650 other => panic!("expected AmbiguousId, got: {other:?}"),
2651 }
2652 }
2653
2654 #[tokio::test]
2656 async fn test_get_full_uuid_bypass() {
2657 let store = make_test_store().await;
2658 let row = store
2659 .create(serde_json::json!({"title": "bypass-test"}))
2660 .await
2661 .unwrap();
2662 assert_eq!(row.id.len(), 36, "UUID must be 36 chars");
2663 let fetched = store.get(&row.id).await.unwrap();
2665 assert_eq!(fetched.id, row.id);
2666 }
2667
2668 #[tokio::test]
2670 async fn test_update_prefix_match_single() {
2671 let store = make_test_store().await;
2672 let row = store
2673 .create(serde_json::json!({"title": "before"}))
2674 .await
2675 .unwrap();
2676 let prefix = &row.id[..8];
2677 let updated = store
2678 .update(
2679 prefix,
2680 serde_json::json!({"title": "after"}),
2681 UpdateMode::Replace,
2682 )
2683 .await
2684 .unwrap();
2685 assert_eq!(updated.id, row.id);
2686 assert_eq!(updated.data["title"], "after");
2687 }
2688
2689 #[tokio::test]
2691 async fn test_update_prefix_match_ambiguous() {
2692 let store = make_test_store().await;
2693 let id1 = "bbbbbbbb-0000-4000-8000-000000000001".to_string();
2694 let id2 = "bbbbbbbb-0000-4000-8000-000000000002".to_string();
2695 store
2696 .execute_under_savepoint(move |sp| {
2697 sp.execute(
2698 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2699 rusqlite::params![id1, r#"{"title":"b1"}"#],
2700 )?;
2701 sp.execute(
2702 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2703 rusqlite::params![id2, r#"{"title":"b2"}"#],
2704 )?;
2705 Ok(())
2706 })
2707 .await
2708 .unwrap();
2709
2710 let err = store
2711 .update(
2712 "bbbbbbbb",
2713 serde_json::json!({"title": "x"}),
2714 UpdateMode::Replace,
2715 )
2716 .await
2717 .unwrap_err();
2718 assert!(
2719 matches!(err, MiniAppError::AmbiguousId { .. }),
2720 "expected AmbiguousId, got: {err:?}"
2721 );
2722 }
2723
2724 #[tokio::test]
2726 async fn test_delete_prefix_match_single() {
2727 let store = make_test_store().await;
2728 let row = store
2729 .create(serde_json::json!({"title": "to-delete-prefix"}))
2730 .await
2731 .unwrap();
2732 let prefix = &row.id[..8];
2733 store.delete(prefix).await.unwrap();
2734 let err = store.get(&row.id).await.unwrap_err();
2736 assert!(
2737 matches!(err, MiniAppError::NotFound { .. }),
2738 "expected NotFound after delete, got: {err:?}"
2739 );
2740 }
2741
2742 #[tokio::test]
2744 async fn test_delete_prefix_match_ambiguous() {
2745 let store = make_test_store().await;
2746 let id1 = "cccccccc-0000-4000-8000-000000000001".to_string();
2747 let id2 = "cccccccc-0000-4000-8000-000000000002".to_string();
2748 store
2749 .execute_under_savepoint(move |sp| {
2750 sp.execute(
2751 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2752 rusqlite::params![id1, r#"{"title":"c1"}"#],
2753 )?;
2754 sp.execute(
2755 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2756 rusqlite::params![id2, r#"{"title":"c2"}"#],
2757 )?;
2758 Ok(())
2759 })
2760 .await
2761 .unwrap();
2762
2763 let err = store.delete("cccccccc").await.unwrap_err();
2764 assert!(
2765 matches!(err, MiniAppError::AmbiguousId { .. }),
2766 "expected AmbiguousId, got: {err:?}"
2767 );
2768 }
2769}