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 history_enabled = self.schema.history.enabled();
430 let conn = self.conn.clone();
431 let id_inner = id.clone();
432 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
433 let mut conn = conn
434 .lock()
435 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
436 let tx = conn.transaction()?;
437 tx.execute(
438 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
439 rusqlite::params![id_inner, data_str, now, now],
440 )?;
441 if history_enabled {
442 crate::row_history::record_in_tx(
443 &tx,
444 &table_name,
445 &id_inner,
446 crate::row_history::HistoryOp::Create,
447 Some(&serde_json::to_string(&value).expect("infallible")),
448 now,
449 )?;
450 }
451 tx.commit()?;
452 Ok(RowRecord {
453 id: id_inner,
454 data: value,
455 created_at: now,
456 updated_at: now,
457 })
458 })
459 .await
460 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
461
462 crate::dump::on_change(&self.schema, &record).await?;
464
465 Ok(record)
466 }
467
468 pub async fn get(&self, id: &str) -> Result<RowRecord, MiniAppError> {
487 let conn = self.conn.clone();
488 let id = id.to_string();
489
490 tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
491 let conn = conn
492 .lock()
493 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
494 let id = resolve_id(&conn, &id)?;
495 let mut stmt =
496 conn.prepare("SELECT id, data, created_at, updated_at FROM rows WHERE id = ?1")?;
497 let row = stmt
498 .query_row(rusqlite::params![id], |row| {
499 Ok((
500 row.get::<_, String>(0)?,
501 row.get::<_, String>(1)?,
502 row.get::<_, i64>(2)?,
503 row.get::<_, i64>(3)?,
504 ))
505 })
506 .optional()?
507 .ok_or_else(|| MiniAppError::NotFound { id: id.clone() })?;
508
509 let data = parse_data(&row.1)?;
510 Ok(RowRecord {
511 id: row.0,
512 data,
513 created_at: row.2,
514 updated_at: row.3,
515 })
516 })
517 .await
518 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
519 }
520
521 pub async fn list(
542 &self,
543 limit: Option<u32>,
544 offset: Option<u32>,
545 filter: Option<ListFilter>,
546 order_by: Option<Vec<OrderByItem>>,
547 ) -> Result<Vec<RowRecord>, MiniAppError> {
548 let conn = self.conn.clone();
549 let limit = limit.unwrap_or(100).min(1000) as i64;
550 let offset = offset.unwrap_or(0) as i64;
551
552 let (where_clause, filter_params) = match filter {
554 None => (String::new(), Vec::new()),
555 Some(f) => {
556 let (fragment, params) = f.build_sql()?;
557 (format!(" WHERE {fragment}"), params)
558 }
559 };
560
561 let order_by_clause = match &order_by {
566 None => " ORDER BY created_at DESC".to_string(),
567 Some(items) if items.is_empty() => " ORDER BY created_at DESC".to_string(),
568 Some(items) => format!(" ORDER BY {}", crate::order_by::build_order_by_sql(items)),
569 };
570
571 tokio::task::spawn_blocking(move || -> Result<Vec<RowRecord>, MiniAppError> {
572 let conn = conn
573 .lock()
574 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
575 let sql = format!(
576 "SELECT id, data, created_at, updated_at FROM rows{where_clause}{order_by_clause} LIMIT ? OFFSET ?"
577 );
578 let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = filter_params
580 .into_iter()
581 .map(|p| -> Box<dyn rusqlite::ToSql> { Box::new(p) })
582 .collect();
583 all_params.push(Box::new(limit));
584 all_params.push(Box::new(offset));
585
586 let mut stmt = conn.prepare(&sql)?;
587 let rows = stmt
588 .query_map(
589 params_from_iter(all_params.iter().map(|p| p.as_ref())),
590 |row| {
591 Ok((
592 row.get::<_, String>(0)?,
593 row.get::<_, String>(1)?,
594 row.get::<_, i64>(2)?,
595 row.get::<_, i64>(3)?,
596 ))
597 },
598 )?
599 .map(|r| {
600 r.map_err(MiniAppError::Storage).and_then(|row| {
601 let data = parse_data(&row.1)?;
602 Ok(RowRecord {
603 id: row.0,
604 data,
605 created_at: row.2,
606 updated_at: row.3,
607 })
608 })
609 })
610 .collect::<Result<Vec<_>, _>>()?;
611 Ok(rows)
612 })
613 .await
614 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
615 }
616
617 pub async fn row_count(&self) -> Result<u64, MiniAppError> {
630 let conn = self.conn.clone();
631 tokio::task::spawn_blocking(move || -> Result<u64, MiniAppError> {
632 let conn = conn
633 .lock()
634 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
635 let count: i64 = conn.query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))?;
636 Ok(count.max(0) as u64)
637 })
638 .await
639 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
640 }
641
642 pub async fn update(
686 &self,
687 id: &str,
688 value: serde_json::Value,
689 mode: UpdateMode,
690 ) -> Result<RowRecord, MiniAppError> {
691 let now = now_secs();
692 let conn = self.conn.clone();
693 let id_str = id.to_string();
694 let schema = self.schema.clone();
695 let table_name = self.schema.table.clone();
696
697 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
698 let mut conn = conn
699 .lock()
700 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
701 let id_str = resolve_id(&conn, &id_str)?;
702
703 let row_data: Option<(String, i64)> = conn
707 .query_row(
708 "SELECT data, created_at FROM rows WHERE id = ?1",
709 rusqlite::params![id_str],
710 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
711 )
712 .optional()?;
713
714 let (current_data_str, created_at) =
715 row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
716
717 let merged = match mode {
718 UpdateMode::Merge => {
719 let current: serde_json::Value = parse_data(¤t_data_str)?;
720 let merged = shallow_merge(current, value, &schema)?;
721 schema.validate(&merged)?;
723 merged
724 }
725 UpdateMode::Replace => {
726 schema.validate(&value)?;
729 value
730 }
731 };
732
733 let merged_str = serde_json::to_string(&merged)
734 .expect("serde_json::Value serialization is infallible");
735
736 let tx = conn.transaction()?;
737 tx.execute(
738 "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
739 rusqlite::params![merged_str, now, id_str],
740 )?;
741 if schema.history.enabled() {
742 crate::row_history::record_in_tx(
743 &tx,
744 &table_name,
745 &id_str,
746 crate::row_history::HistoryOp::Update,
747 Some(&merged_str),
748 now,
749 )?;
750 }
751 tx.commit()?;
752
753 Ok(RowRecord {
754 id: id_str,
755 data: merged,
756 created_at,
757 updated_at: now,
758 })
759 })
760 .await
761 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
762
763 crate::dump::on_change(&self.schema, &record).await?;
765
766 Ok(record)
767 }
768
769 pub async fn execute_under_savepoint<F, R>(&self, f: F) -> Result<R, MiniAppError>
805 where
806 F: FnOnce(&mut rusqlite::Savepoint<'_>) -> Result<R, MiniAppError> + Send + 'static,
807 R: Send + 'static,
808 {
809 let conn = self.conn.clone();
810 tokio::task::spawn_blocking(move || -> Result<R, MiniAppError> {
811 let mut guard = conn
812 .lock()
813 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
814 let mut sp = guard.savepoint()?;
815 sp.set_drop_behavior(rusqlite::DropBehavior::Rollback);
817 let result = f(&mut sp)?;
818 sp.commit()?;
819 Ok(result)
820 })
821 .await
822 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
823 }
824
825 pub async fn delete(&self, id: &str) -> Result<(), MiniAppError> {
867 let conn = self.conn.clone();
868 let id = id.to_string();
869 let table_name = self.schema.table.clone();
870 let history_enabled = self.schema.history.enabled();
871 let now = now_secs();
872
873 let resolved_id = tokio::task::spawn_blocking(move || -> Result<String, MiniAppError> {
876 let mut conn = conn
877 .lock()
878 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
879 let resolved = resolve_id(&conn, &id)?;
880 let data_str: Option<String> = conn
882 .query_row(
883 "SELECT data FROM rows WHERE id = ?1",
884 rusqlite::params![resolved],
885 |row| row.get::<_, String>(0),
886 )
887 .optional()?;
888 let data_str = data_str.ok_or_else(|| MiniAppError::NotFound {
889 id: resolved.clone(),
890 })?;
891 let tx = conn.transaction()?;
892 let n = tx.execute(
893 "DELETE FROM rows WHERE id = ?1",
894 rusqlite::params![resolved],
895 )?;
896 if n == 0 {
897 return Err(MiniAppError::NotFound { id: resolved });
899 }
900 if history_enabled {
901 crate::row_history::record_in_tx(
902 &tx,
903 &table_name,
904 &resolved,
905 crate::row_history::HistoryOp::Delete,
906 Some(&data_str),
907 now,
908 )?;
909 }
910 tx.commit()?;
911 Ok(resolved)
912 })
913 .await
914 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
915
916 crate::dump::on_delete(&self.schema, &resolved_id).await?;
918
919 Ok(())
920 }
921
922 pub async fn restore_row(
938 &self,
939 id: &str,
940 data: serde_json::Value,
941 ) -> Result<RowRecord, MiniAppError> {
942 self.schema.validate(&data)?;
943 let now = now_secs();
944 let id_str = id.to_string();
945 let data_str =
946 serde_json::to_string(&data).expect("serde_json::Value serialization is infallible");
947 let table_name = self.schema.table.clone();
948 let history_enabled = self.schema.history.enabled();
949 let conn = self.conn.clone();
950
951 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
952 let mut conn = conn
953 .lock()
954 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
955 let tx = conn.transaction()?;
956 tx.execute(
957 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
958 rusqlite::params![id_str, data_str, now, now],
959 )?;
960 if history_enabled {
961 crate::row_history::record_in_tx(
962 &tx,
963 &table_name,
964 &id_str,
965 crate::row_history::HistoryOp::Create,
966 Some(&data_str),
967 now,
968 )?;
969 }
970 tx.commit()?;
971 Ok(RowRecord {
972 id: id_str,
973 data,
974 created_at: now,
975 updated_at: now,
976 })
977 })
978 .await
979 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
980
981 crate::dump::on_change(&self.schema, &record).await?;
982
983 Ok(record)
984 }
985
986 pub async fn fetch_history_at(
999 &self,
1000 id: &str,
1001 at_unix_secs: i64,
1002 ) -> Result<Option<crate::row_history::HistoryRecord>, MiniAppError> {
1003 if !self.schema.history.enabled() {
1009 return Ok(None);
1010 }
1011 let conn = self.conn.clone();
1012 let table_name = self.schema.table.clone();
1013 let id_str = id.to_string();
1014 tokio::task::spawn_blocking(
1015 move || -> Result<Option<crate::row_history::HistoryRecord>, MiniAppError> {
1016 let conn = conn
1017 .lock()
1018 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1019 crate::row_history::fetch_at(&conn, &table_name, &id_str, at_unix_secs)
1020 .map_err(MiniAppError::Storage)
1021 },
1022 )
1023 .await
1024 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1025 }
1026
1027 pub async fn alias_create(
1047 &self,
1048 name: &str,
1049 filter_json: &str,
1050 default_limit: Option<u32>,
1051 description: Option<String>,
1052 params_schema: Option<String>,
1053 ) -> Result<(), MiniAppError> {
1054 let conn = self.conn.clone();
1055 let name = name.to_string();
1056 let filter_json = filter_json.to_string();
1057
1058 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
1059 let conn = conn
1060 .lock()
1061 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1062 conn.execute(
1063 "INSERT OR IGNORE INTO _aliases \
1064 (name, filter, default_limit, description, params_schema) \
1065 VALUES (?1, ?2, ?3, ?4, ?5)",
1066 rusqlite::params![name, filter_json, default_limit, description, params_schema],
1067 )?;
1068 if conn.changes() == 0 {
1069 return Err(MiniAppError::AliasAlreadyExists { name });
1070 }
1071 Ok(())
1072 })
1073 .await
1074 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1075 }
1076
1077 pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
1087 let conn = self.conn.clone();
1088 let name = name.to_string();
1089
1090 tokio::task::spawn_blocking(move || -> Result<AliasRecord, MiniAppError> {
1091 let conn = conn
1092 .lock()
1093 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1094 let mut stmt = conn.prepare(
1095 "SELECT name, filter, default_limit, description, params_schema \
1096 FROM _aliases WHERE name = ?1",
1097 )?;
1098 let record = stmt
1099 .query_row(rusqlite::params![name], |row| {
1100 Ok((
1101 row.get::<_, String>(0)?,
1102 row.get::<_, String>(1)?,
1103 row.get::<_, Option<u32>>(2)?,
1104 row.get::<_, Option<String>>(3)?,
1105 row.get::<_, Option<String>>(4)?,
1106 ))
1107 })
1108 .optional()?
1109 .ok_or_else(|| MiniAppError::AliasNotFound { name: name.clone() })?;
1110
1111 Ok(AliasRecord {
1112 name: record.0,
1113 filter: record.1,
1114 default_limit: record.2,
1115 description: record.3,
1116 params_schema: record.4,
1117 })
1118 })
1119 .await
1120 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1121 }
1122
1123 pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
1134 let conn = self.conn.clone();
1135
1136 tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
1137 let conn = conn
1138 .lock()
1139 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1140 let mut stmt = conn.prepare(
1141 "SELECT name, filter, default_limit, description, params_schema \
1142 FROM _aliases ORDER BY name ASC",
1143 )?;
1144 let records = stmt
1145 .query_map([], |row| {
1146 Ok((
1147 row.get::<_, String>(0)?,
1148 row.get::<_, String>(1)?,
1149 row.get::<_, Option<u32>>(2)?,
1150 row.get::<_, Option<String>>(3)?,
1151 row.get::<_, Option<String>>(4)?,
1152 ))
1153 })?
1154 .collect::<Result<Vec<_>, _>>()?;
1155
1156 Ok(records
1157 .into_iter()
1158 .map(
1159 |(name, filter, default_limit, description, params_schema)| AliasRecord {
1160 name,
1161 filter,
1162 default_limit,
1163 description,
1164 params_schema,
1165 },
1166 )
1167 .collect())
1168 })
1169 .await
1170 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1171 }
1172
1173 pub async fn alias_delete(&self, name: &str) -> Result<(), MiniAppError> {
1183 let conn = self.conn.clone();
1184 let name = name.to_string();
1185
1186 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
1187 let conn = conn
1188 .lock()
1189 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1190 let n = conn.execute(
1191 "DELETE FROM _aliases WHERE name = ?1",
1192 rusqlite::params![name],
1193 )?;
1194 if n == 0 {
1195 return Err(MiniAppError::AliasNotFound { name });
1196 }
1197 Ok(())
1198 })
1199 .await
1200 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1201 }
1202
1203 pub async fn view_string_field(
1217 &self,
1218 id: &str,
1219 field: &str,
1220 view_range: Option<(u32, u32)>,
1221 ) -> Result<String, MiniAppError> {
1222 let conn = self.conn.clone();
1223 let id_str = id.to_string();
1224 let field = field.to_string();
1225 let schema = self.schema.clone();
1226
1227 tokio::task::spawn_blocking(move || -> Result<String, MiniAppError> {
1228 let conn = conn
1229 .lock()
1230 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1231 let id_str = resolve_id(&conn, &id_str)?;
1232
1233 let field_def = schema
1234 .fields
1235 .iter()
1236 .find(|f| f.name == field)
1237 .ok_or_else(|| MiniAppError::Schema(format!("field `{field}` not in schema")))?;
1238 if field_def.ty != FieldType::String {
1239 return Err(MiniAppError::FieldTypeError {
1240 field: field.clone(),
1241 actual_type: field_def.ty.as_str().to_string(),
1242 });
1243 }
1244
1245 let row_data: Option<String> = conn
1246 .query_row(
1247 "SELECT data FROM rows WHERE id = ?1",
1248 rusqlite::params![id_str],
1249 |row| row.get::<_, String>(0),
1250 )
1251 .optional()?;
1252 let data_str = row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
1253
1254 let data = parse_data(&data_str)?;
1255 let text = data
1256 .get(&field)
1257 .and_then(|v| v.as_str())
1258 .unwrap_or("")
1259 .to_string();
1260
1261 let lines: Vec<&str> = text.split('\n').collect();
1262 let total = lines.len() as u32;
1263 let (start, end) = match view_range {
1264 Some((s, e)) => (s.max(1), e.min(total)),
1265 None => (1, total),
1266 };
1267
1268 let mut out = String::new();
1269 for (i, line) in lines.iter().enumerate() {
1270 let lineno = i as u32 + 1;
1271 if lineno >= start && lineno <= end {
1272 out.push_str(&format!("{:6} {}\n", lineno, line));
1273 }
1274 }
1275 Ok(out)
1276 })
1277 .await
1278 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1279 }
1280
1281 pub async fn replace_string_field(
1298 &self,
1299 id: &str,
1300 field: &str,
1301 old_str: &str,
1302 new_str: &str,
1303 view_range: Option<(u32, u32)>,
1304 replace_all: bool,
1305 ) -> Result<ReplaceResult, MiniAppError> {
1306 let now = now_secs();
1307 let conn = self.conn.clone();
1308 let id_str = id.to_string();
1309 let field = field.to_string();
1310 let old_str = old_str.to_string();
1311 let new_str = new_str.to_string();
1312 let schema = self.schema.clone();
1313 let table_name = self.schema.table.clone();
1314
1315 let (record, matches) =
1316 tokio::task::spawn_blocking(move || -> Result<(RowRecord, u32), MiniAppError> {
1317 let mut conn = conn
1318 .lock()
1319 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1320 let id_str = resolve_id(&conn, &id_str)?;
1321
1322 let field_def =
1323 schema
1324 .fields
1325 .iter()
1326 .find(|f| f.name == field)
1327 .ok_or_else(|| {
1328 MiniAppError::Schema(format!("field `{field}` not in schema"))
1329 })?;
1330 if field_def.ty != FieldType::String {
1331 return Err(MiniAppError::FieldTypeError {
1332 field: field.clone(),
1333 actual_type: field_def.ty.as_str().to_string(),
1334 });
1335 }
1336
1337 if old_str.is_empty() {
1339 return Err(MiniAppError::Validation {
1340 field: field.clone(),
1341 reason: "old_str must not be empty".to_string(),
1342 });
1343 }
1344
1345 let row_data: Option<(String, i64)> = conn
1346 .query_row(
1347 "SELECT data, created_at FROM rows WHERE id = ?1",
1348 rusqlite::params![id_str],
1349 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
1350 )
1351 .optional()?;
1352 let (data_str, created_at) =
1353 row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
1354
1355 let mut data = parse_data(&data_str)?;
1356 let text = data
1357 .get(&field)
1358 .and_then(|v| v.as_str())
1359 .unwrap_or("")
1360 .to_string();
1361
1362 if let Some((s, e)) = view_range {
1364 if s == 0 || s > e {
1365 return Err(MiniAppError::Validation {
1366 field: field.clone(),
1367 reason: format!(
1368 "view_range [{s}, {e}] is invalid: start must be ≥ 1 and ≤ end"
1369 ),
1370 });
1371 }
1372 }
1373
1374 let search_scope = if let Some((s, e)) = view_range {
1376 let lines: Vec<&str> = text.split('\n').collect();
1377 let total = lines.len() as u32;
1378 let si = (s.max(1).saturating_sub(1)) as usize;
1379 let ei = (e.min(total) as usize).min(lines.len());
1380 lines[si..ei].join("\n")
1381 } else {
1382 text.clone()
1383 };
1384
1385 const CAND_CAP: usize = 20;
1391 let mut candidates: Vec<crate::error::MatchCandidate> = Vec::new();
1392 let mut match_count = 0u32;
1393 let mut pos = 0usize;
1394 let old_len = old_str.len().max(1);
1395 while let Some(rel) = search_scope[pos..].find(old_str.as_str()) {
1396 let abs = pos + rel;
1397 match_count += 1;
1398 if candidates.len() < CAND_CAP {
1399 let before = &search_scope[..abs];
1400 let line_no = (before.matches('\n').count() as u32) + 1;
1401 let col = (abs - before.rfind('\n').map(|p| p + 1).unwrap_or(0)) as u32 + 1;
1402 let mut snip_start = abs.saturating_sub(30);
1403 while snip_start < abs && !search_scope.is_char_boundary(snip_start) {
1404 snip_start += 1;
1405 }
1406 let mut snip_end = (abs + old_str.len() + 30).min(search_scope.len());
1407 while snip_end > abs + old_str.len()
1408 && !search_scope.is_char_boundary(snip_end)
1409 {
1410 snip_end -= 1;
1411 }
1412 candidates.push(crate::error::MatchCandidate {
1413 line: line_no,
1414 col,
1415 snippet: search_scope[snip_start..snip_end].to_string(),
1416 });
1417 }
1418 pos = abs + old_len;
1419 }
1420 if match_count == 0 {
1421 return Err(MiniAppError::StringNotFound {
1422 field: field.clone(),
1423 });
1424 }
1425 if !replace_all && match_count >= 2 {
1426 return Err(MiniAppError::AmbiguousMatch {
1427 field: field.clone(),
1428 matches: match_count,
1429 candidates,
1430 });
1431 }
1432
1433 let new_text = if let Some((s, e)) = view_range {
1435 let lines: Vec<String> = text.split('\n').map(str::to_string).collect();
1436 let total = lines.len() as u32;
1437 let si = (s.max(1).saturating_sub(1)) as usize;
1438 let ei = (e.min(total) as usize).min(lines.len());
1439 let scoped = lines[si..ei].join("\n");
1440 let replaced_scoped = if replace_all {
1441 scoped.replace(old_str.as_str(), new_str.as_str())
1442 } else {
1443 scoped.replacen(old_str.as_str(), new_str.as_str(), 1)
1444 };
1445 let mut all_lines: Vec<String> = lines[..si].to_vec();
1446 all_lines.extend(replaced_scoped.split('\n').map(str::to_string));
1447 all_lines.extend_from_slice(&lines[ei..]);
1448 all_lines.join("\n")
1449 } else if replace_all {
1450 text.replace(old_str.as_str(), new_str.as_str())
1451 } else {
1452 text.replacen(old_str.as_str(), new_str.as_str(), 1)
1453 };
1454
1455 data.as_object_mut()
1457 .expect("data is always a JSON object")
1458 .insert(field.clone(), serde_json::Value::String(new_text));
1459 schema.validate(&data)?;
1460
1461 let new_data_str =
1462 serde_json::to_string(&data).expect("serialization is infallible");
1463
1464 let tx = conn.transaction()?;
1466 tx.execute(
1467 "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
1468 rusqlite::params![new_data_str, now, id_str],
1469 )?;
1470 if schema.history.enabled() {
1471 crate::row_history::record_in_tx(
1472 &tx,
1473 &table_name,
1474 &id_str,
1475 crate::row_history::HistoryOp::Update,
1476 Some(&new_data_str),
1477 now,
1478 )?;
1479 }
1480 tx.commit()?;
1481
1482 Ok((
1483 RowRecord {
1484 id: id_str,
1485 data,
1486 created_at,
1487 updated_at: now,
1488 },
1489 match_count,
1490 ))
1491 })
1492 .await
1493 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
1494
1495 crate::dump::on_change(&self.schema, &record).await?;
1496 Ok(ReplaceResult { matches })
1497 }
1498
1499 pub async fn insert_into_string_field(
1512 &self,
1513 id: &str,
1514 field: &str,
1515 line: u32,
1516 content: &str,
1517 ) -> Result<(), MiniAppError> {
1518 let now = now_secs();
1519 let conn = self.conn.clone();
1520 let id_str = id.to_string();
1521 let field = field.to_string();
1522 let content = content.to_string();
1523 let schema = self.schema.clone();
1524 let table_name = self.schema.table.clone();
1525
1526 let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
1527 let mut conn = conn
1528 .lock()
1529 .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1530 let id_str = resolve_id(&conn, &id_str)?;
1531
1532 let field_def = schema
1533 .fields
1534 .iter()
1535 .find(|f| f.name == field)
1536 .ok_or_else(|| MiniAppError::Schema(format!("field `{field}` not in schema")))?;
1537 if field_def.ty != FieldType::String {
1538 return Err(MiniAppError::FieldTypeError {
1539 field: field.clone(),
1540 actual_type: field_def.ty.as_str().to_string(),
1541 });
1542 }
1543
1544 let row_data: Option<(String, i64)> = conn
1545 .query_row(
1546 "SELECT data, created_at FROM rows WHERE id = ?1",
1547 rusqlite::params![id_str],
1548 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
1549 )
1550 .optional()?;
1551 let (data_str, created_at) =
1552 row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
1553
1554 let mut data = parse_data(&data_str)?;
1555 let text = data
1556 .get(&field)
1557 .and_then(|v| v.as_str())
1558 .unwrap_or("")
1559 .to_string();
1560
1561 let mut lines: Vec<String> = text.split('\n').map(str::to_string).collect();
1562 let total = lines.len() as u32;
1563
1564 if line > total {
1566 return Err(MiniAppError::LineOutOfRange {
1567 line,
1568 total_lines: total,
1569 });
1570 }
1571 lines.insert(line as usize, content.clone());
1573
1574 let new_text = lines.join("\n");
1575 data.as_object_mut()
1576 .expect("data is always a JSON object")
1577 .insert(field.clone(), serde_json::Value::String(new_text));
1578 schema.validate(&data)?;
1579
1580 let new_data_str = serde_json::to_string(&data).expect("serialization is infallible");
1581
1582 let tx = conn.transaction()?;
1583 tx.execute(
1584 "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
1585 rusqlite::params![new_data_str, now, id_str],
1586 )?;
1587 if schema.history.enabled() {
1588 crate::row_history::record_in_tx(
1589 &tx,
1590 &table_name,
1591 &id_str,
1592 crate::row_history::HistoryOp::Update,
1593 Some(&new_data_str),
1594 now,
1595 )?;
1596 }
1597 tx.commit()?;
1598
1599 Ok(RowRecord {
1600 id: id_str,
1601 data,
1602 created_at,
1603 updated_at: now,
1604 })
1605 })
1606 .await
1607 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
1608
1609 crate::dump::on_change(&self.schema, &record).await?;
1610 Ok(())
1611 }
1612}
1613
1614#[cfg(test)]
1619mod tests {
1620 use std::sync::Arc;
1621
1622 use super::*;
1623 use crate::schema::{FieldDef, FieldType};
1624
1625 async fn make_test_store() -> Store {
1626 let schema = SchemaConfig {
1627 table: "test".into(),
1628 title: None,
1629 description: None,
1630 fields: vec![
1631 FieldDef {
1632 name: "title".into(),
1633 ty: FieldType::String,
1634 required: true,
1635 description: None,
1636 },
1637 FieldDef {
1638 name: "state".into(),
1639 ty: FieldType::String,
1640 required: false,
1641 description: None,
1642 },
1643 ],
1644 dump: None,
1645 history: Default::default(),
1646 };
1647 Store::open(Path::new(":memory:"), schema).await.unwrap()
1648 }
1649
1650 async fn make_test_store_with_dump(dir: &Path) -> Store {
1652 use crate::dump::{DumpConfig, SyncMode};
1653 let schema = SchemaConfig {
1654 table: "test".into(),
1655 title: None,
1656 description: None,
1657 fields: vec![
1658 FieldDef {
1659 name: "title".into(),
1660 ty: FieldType::String,
1661 required: true,
1662 description: None,
1663 },
1664 FieldDef {
1665 name: "body".into(),
1666 ty: FieldType::String,
1667 required: false,
1668 description: None,
1669 },
1670 ],
1671 history: Default::default(),
1672 dump: Some(DumpConfig {
1673 dir: Some(dir.to_path_buf()),
1674 title_field: None,
1675 body_field: None,
1676 sync: Some(SyncMode::WriteOnly),
1677 }),
1678 };
1679 Store::open(Path::new(":memory:"), schema).await.unwrap()
1680 }
1681
1682 #[tokio::test]
1685 async fn test_history_off_records_nothing() {
1686 let schema = SchemaConfig {
1687 table: "test".into(),
1688 title: None,
1689 description: None,
1690 fields: vec![FieldDef {
1691 name: "title".into(),
1692 ty: FieldType::String,
1693 required: true,
1694 description: None,
1695 }],
1696 dump: None,
1697 history: crate::schema::HistoryMode::Off,
1698 };
1699 let store = Store::open(Path::new(":memory:"), schema).await.unwrap();
1700 let row = store
1701 .create(serde_json::json!({"title": "a"}))
1702 .await
1703 .unwrap();
1704 store
1705 .update(
1706 &row.id,
1707 serde_json::json!({"title": "b"}),
1708 UpdateMode::Merge,
1709 )
1710 .await
1711 .unwrap();
1712 store.delete(&row.id).await.unwrap();
1713
1714 let n: i64 = store
1715 .conn_for_test()
1716 .query_row("SELECT COUNT(*) FROM _row_history", [], |r| r.get(0))
1717 .unwrap();
1718 assert_eq!(n, 0, "history: off must not record any entry");
1719
1720 assert!(
1723 store
1724 .fetch_history_at(&row.id, i64::MAX)
1725 .await
1726 .unwrap()
1727 .is_none()
1728 );
1729 }
1730
1731 #[tokio::test]
1732 async fn test_history_full_still_records() {
1733 let store = make_test_store().await;
1734 let row = store
1735 .create(serde_json::json!({"title": "a"}))
1736 .await
1737 .unwrap();
1738 store
1739 .update(
1740 &row.id,
1741 serde_json::json!({"title": "b"}),
1742 UpdateMode::Merge,
1743 )
1744 .await
1745 .unwrap();
1746 let versions = store.conn_for_test();
1747 let n: i64 = versions
1748 .query_row("SELECT COUNT(*) FROM _row_history", [], |r| r.get(0))
1749 .unwrap();
1750 assert_eq!(n, 2);
1751 let prev_n: i64 = versions
1752 .query_row(
1753 "SELECT COUNT(*) FROM _row_history WHERE prev_data_json IS NOT NULL",
1754 [],
1755 |r| r.get(0),
1756 )
1757 .unwrap();
1758 assert_eq!(prev_n, 0, "prev_data_json must no longer be written");
1759 }
1760
1761 #[tokio::test]
1762 async fn test_create_and_get_roundtrip() {
1763 let store = make_test_store().await;
1764 let value = serde_json::json!({"title": "hello", "state": "open"});
1765 let row = store.create(value.clone()).await.unwrap();
1766 let fetched = store.get(&row.id).await.unwrap();
1767 assert_eq!(fetched.id, row.id);
1768 assert_eq!(fetched.data, value);
1769 }
1770
1771 #[tokio::test]
1772 async fn test_create_then_list() {
1773 let store = make_test_store().await;
1774 store
1775 .create(serde_json::json!({"title": "t1"}))
1776 .await
1777 .unwrap();
1778 let rows = store.list(None, None, None, None).await.unwrap();
1779 assert_eq!(rows.len(), 1);
1780 }
1781
1782 #[tokio::test]
1783 async fn test_list_limit_offset() {
1784 let store = make_test_store().await;
1785 for i in 0..5 {
1786 store
1787 .create(serde_json::json!({"title": format!("item-{i}")}))
1788 .await
1789 .unwrap();
1790 }
1791 let page1 = store.list(Some(2), Some(0), None, None).await.unwrap();
1792 assert_eq!(page1.len(), 2);
1793 let page2 = store.list(Some(2), Some(2), None, None).await.unwrap();
1794 assert_eq!(page2.len(), 2);
1795 let page3 = store.list(Some(2), Some(4), None, None).await.unwrap();
1796 assert_eq!(page3.len(), 1);
1797 }
1798
1799 #[tokio::test]
1800 async fn test_update_timestamps() {
1801 let store = make_test_store().await;
1802 let row = store
1803 .create(serde_json::json!({"title": "original"}))
1804 .await
1805 .unwrap();
1806 let updated = store
1810 .update(
1811 &row.id,
1812 serde_json::json!({"title": "changed"}),
1813 UpdateMode::Replace,
1814 )
1815 .await
1816 .unwrap();
1817 assert_eq!(updated.created_at, row.created_at);
1818 assert_eq!(updated.id, row.id);
1819 assert_eq!(updated.data["title"], "changed");
1820 }
1821
1822 #[tokio::test]
1823 async fn test_create_delete_get_not_found() {
1824 let store = make_test_store().await;
1825 let row = store
1826 .create(serde_json::json!({"title": "to-delete"}))
1827 .await
1828 .unwrap();
1829 store.delete(&row.id).await.unwrap();
1830 let err = store.get(&row.id).await.unwrap_err();
1831 assert!(matches!(err, MiniAppError::NotFound { .. }));
1832 }
1833
1834 #[tokio::test]
1835 async fn test_get_unknown_id_not_found() {
1836 let store = make_test_store().await;
1837 let err = store.get("nonexistent-id").await.unwrap_err();
1838 assert!(matches!(err, MiniAppError::NotFound { .. }));
1839 }
1840
1841 #[tokio::test]
1842 async fn test_update_unknown_id_not_found() {
1843 let store = make_test_store().await;
1844 let err = store
1845 .update(
1846 "nonexistent-id",
1847 serde_json::json!({"title": "x"}),
1848 UpdateMode::Replace,
1849 )
1850 .await
1851 .unwrap_err();
1852 assert!(matches!(err, MiniAppError::NotFound { .. }));
1853 }
1854
1855 #[tokio::test]
1856 async fn test_delete_unknown_id_not_found() {
1857 let store = make_test_store().await;
1858 let err = store.delete("nonexistent-id").await.unwrap_err();
1859 assert!(matches!(err, MiniAppError::NotFound { .. }));
1860 }
1861
1862 #[tokio::test]
1863 async fn test_create_missing_required_field_validation_error() {
1864 let store = make_test_store().await;
1865 let err = store
1867 .create(serde_json::json!({"state": "open"}))
1868 .await
1869 .unwrap_err();
1870 assert!(
1871 matches!(err, MiniAppError::Validation { .. }),
1872 "expected Validation, got: {err:?}"
1873 );
1874 }
1875
1876 #[tokio::test]
1877 async fn test_create_type_mismatch_validation_error() {
1878 let store = make_test_store().await;
1879 let err = store
1881 .create(serde_json::json!({"title": 42}))
1882 .await
1883 .unwrap_err();
1884 assert!(
1885 matches!(err, MiniAppError::Validation { .. }),
1886 "expected Validation, got: {err:?}"
1887 );
1888 }
1889
1890 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1893 async fn test_store_create_concurrent() {
1894 let store = Arc::new(make_test_store().await);
1895 let handles: Vec<_> = (0..4)
1896 .map(|i| {
1897 let s = store.clone();
1898 tokio::spawn(async move {
1899 s.create(serde_json::json!({"title": format!("task-{i}"), "state": "open"}))
1900 .await
1901 })
1902 })
1903 .collect();
1904 let results: Vec<_> = futures::future::join_all(handles).await;
1905 assert!(results.iter().all(|r| r.as_ref().unwrap().is_ok()));
1906 let rows = store.list(None, None, None, None).await.unwrap();
1907 assert_eq!(rows.len(), 4);
1908 }
1909
1910 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1911 async fn test_store_mutex_no_await_holding_lock() {
1912 let store = Arc::new(make_test_store().await);
1913 let id = store
1914 .create(serde_json::json!({"title": "init", "state": "open"}))
1915 .await
1916 .unwrap()
1917 .id;
1918 let s1 = store.clone();
1919 let id1 = id.clone();
1920 let h1 = tokio::spawn(async move { s1.get(&id1).await });
1921 let s2 = store.clone();
1922 let id2 = id.clone();
1923 let h2 = tokio::spawn(async move {
1924 s2.update(
1925 &id2,
1926 serde_json::json!({"title": "updated", "state": "closed"}),
1927 UpdateMode::Replace,
1928 )
1929 .await
1930 });
1931 let (r1, r2) = tokio::join!(h1, h2);
1932 assert!(r1.unwrap().is_ok());
1933 assert!(r2.unwrap().is_ok());
1934 }
1935
1936 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1937 async fn test_store_arc_clone_across_tasks() {
1938 let store = Arc::new(make_test_store().await);
1939 let handles: Vec<_> = (0..8)
1940 .map(|i| {
1941 let s = Arc::clone(&store);
1942 tokio::spawn(async move {
1943 s.create(serde_json::json!({"title": format!("row-{i}"), "state": "open"}))
1944 .await
1945 })
1946 })
1947 .collect();
1948 futures::future::join_all(handles).await;
1949 assert_eq!(store.list(None, None, None, None).await.unwrap().len(), 8);
1950 }
1951
1952 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1953 async fn test_spawn_blocking_join_error_propagation() {
1954 let result: Result<(), _> = tokio::task::spawn_blocking(|| panic!("intentional"))
1955 .await
1956 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")));
1957 assert!(matches!(result, Err(MiniAppError::Schema(_))));
1958 }
1959
1960 #[tokio::test]
1963 async fn create_triggers_dump_when_configured() {
1964 let tmp = tempfile::tempdir().expect("tempdir");
1965 let store = make_test_store_with_dump(tmp.path()).await;
1966 let row = store
1967 .create(serde_json::json!({"title": "My Issue", "body": "Details"}))
1968 .await
1969 .expect("create ok");
1970 let dump_file = tmp.path().join(format!("{}.md", row.id));
1971 assert!(dump_file.exists(), "dump file must be created after create");
1972 let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1973 assert!(content.starts_with("# My Issue\n"));
1974 assert!(content.contains("Details"));
1975 }
1976
1977 #[tokio::test]
1978 async fn update_overwrites_dump_file() {
1979 let tmp = tempfile::tempdir().expect("tempdir");
1980 let store = make_test_store_with_dump(tmp.path()).await;
1981 let row = store
1982 .create(serde_json::json!({"title": "Original", "body": "v1"}))
1983 .await
1984 .expect("create ok");
1985
1986 store
1987 .update(
1988 &row.id,
1989 serde_json::json!({"title": "Updated", "body": "v2"}),
1990 UpdateMode::Replace,
1991 )
1992 .await
1993 .expect("update ok");
1994
1995 let dump_file = tmp.path().join(format!("{}.md", row.id));
1996 let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1997 assert!(
1998 content.starts_with("# Updated\n"),
1999 "dump file must reflect updated title"
2000 );
2001 assert!(
2002 content.contains("v2"),
2003 "dump file must reflect updated body"
2004 );
2005 }
2006
2007 #[tokio::test]
2008 async fn delete_keeps_dump_file_by_default() {
2009 let tmp = tempfile::tempdir().expect("tempdir");
2010 let store = make_test_store_with_dump(tmp.path()).await;
2011 let row = store
2012 .create(serde_json::json!({"title": "Keep Me", "body": ""}))
2013 .await
2014 .expect("create ok");
2015
2016 let dump_file = tmp.path().join(format!("{}.md", row.id));
2017 assert!(dump_file.exists(), "dump file must exist after create");
2018
2019 store.delete(&row.id).await.expect("delete ok");
2020 assert!(
2021 dump_file.exists(),
2022 "dump file must remain after delete (default: keep)"
2023 );
2024 }
2025
2026 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2027 async fn test_store_create_concurrent_dump_writes_all_files() {
2028 let tmp = tempfile::tempdir().expect("tempdir");
2029 let store = Arc::new(make_test_store_with_dump(tmp.path()).await);
2030
2031 let handles: Vec<_> = (0..4)
2032 .map(|i| {
2033 let s = store.clone();
2034 tokio::spawn(async move {
2035 s.create(serde_json::json!({
2036 "title": format!("concurrent-{i}"),
2037 "body": format!("body-{i}"),
2038 }))
2039 .await
2040 })
2041 })
2042 .collect();
2043
2044 let results: Vec<_> = futures::future::join_all(handles).await;
2045 let rows: Vec<_> = results
2047 .into_iter()
2048 .map(|r| r.expect("spawn ok").expect("create ok"))
2049 .collect();
2050
2051 for row in &rows {
2053 let path = tmp.path().join(format!("{}.md", row.id));
2054 assert!(path.exists(), "dump file must exist for row {}", row.id);
2055 }
2056 assert_eq!(rows.len(), 4);
2057 }
2058
2059 #[tokio::test]
2060 async fn store_open_with_bidirectional_sync_returns_ok() {
2061 use crate::dump::{DumpConfig, SyncMode};
2062 let schema = SchemaConfig {
2065 table: "test".into(),
2066 title: None,
2067 description: None,
2068 fields: vec![FieldDef {
2069 name: "title".into(),
2070 ty: FieldType::String,
2071 required: false,
2072 description: None,
2073 }],
2074 history: Default::default(),
2075 dump: Some(DumpConfig {
2076 dir: None,
2077 title_field: None,
2078 body_field: None,
2079 sync: Some(SyncMode::Bidirectional),
2080 }),
2081 };
2082 let store = Store::open(Path::new(":memory:"), schema).await;
2083 assert!(
2084 store.is_ok(),
2085 "Store::open must succeed even with bidirectional sync configured"
2086 );
2087 }
2088
2089 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2097 async fn test_savepoint_atomic_rollback_on_op_failure() {
2098 let store = make_test_store().await;
2099
2100 let result: Result<(), MiniAppError> = store
2102 .execute_under_savepoint(|sp| {
2103 sp.execute(
2104 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
2105 rusqlite::params!["sp-test-id", r#"{"title":"t"}"#, 1000_i64, 1000_i64],
2106 )?;
2107 Err(MiniAppError::Validation {
2109 field: "test".into(),
2110 reason: "forced rollback".into(),
2111 })
2112 })
2113 .await;
2114
2115 assert!(
2116 result.is_err(),
2117 "execute_under_savepoint must propagate the closure error"
2118 );
2119 assert!(
2120 matches!(result.unwrap_err(), MiniAppError::Validation { .. }),
2121 "error variant must be preserved"
2122 );
2123
2124 let rows = store.list(Some(1000), None, None, None).await.unwrap();
2126 assert_eq!(
2127 rows.len(),
2128 0,
2129 "SAVEPOINT rollback must revert the INSERT (Crux: SAVEPOINT atomicity)"
2130 );
2131
2132 store
2134 .create(serde_json::json!({"title": "after-rollback"}))
2135 .await
2136 .expect("store must be usable after SAVEPOINT rollback");
2137 assert_eq!(store.list(None, None, None, None).await.unwrap().len(), 1);
2138 }
2139
2140 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2142 async fn test_savepoint_commit_on_success() {
2143 let store = make_test_store().await;
2144
2145 let result = store
2146 .execute_under_savepoint(|sp| {
2147 sp.execute(
2148 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
2149 rusqlite::params!["sp-ok-id", r#"{"title":"committed"}"#, 1000_i64, 1000_i64],
2150 )?;
2151 Ok(42_u32)
2152 })
2153 .await;
2154
2155 assert_eq!(
2156 result.unwrap(),
2157 42_u32,
2158 "successful SAVEPOINT must return value"
2159 );
2160
2161 let rows = store.list(Some(10), None, None, None).await.unwrap();
2163 assert_eq!(rows.len(), 1, "committed INSERT must persist");
2164 }
2165
2166 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2169 async fn test_store_concurrent_create() {
2170 let store = Arc::new(make_test_store().await);
2171 let task_count = 8_usize;
2172 let rows_per_task = 100_usize;
2173
2174 let handles: Vec<_> = (0..task_count)
2175 .map(|task_id| {
2176 let s = Arc::clone(&store);
2177 tokio::spawn(async move {
2178 for i in 0..rows_per_task {
2179 s.create(serde_json::json!({"title": format!("task-{task_id}-row-{i}")}))
2180 .await
2181 .expect("concurrent create must succeed");
2182 }
2183 })
2184 })
2185 .collect();
2186
2187 futures::future::join_all(handles)
2188 .await
2189 .into_iter()
2190 .for_each(|r| r.expect("task must not panic"));
2191
2192 let total = store
2193 .list(Some(1000), None, None, None)
2194 .await
2195 .unwrap()
2196 .len();
2197 assert_eq!(
2198 total,
2199 task_count * rows_per_task,
2200 "all {total} rows must be present; expected {}",
2201 task_count * rows_per_task
2202 );
2203 }
2204
2205 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2208 async fn test_store_concurrent_update_same_id() {
2209 let store = Arc::new(make_test_store().await);
2210
2211 let row = store
2213 .create(serde_json::json!({"title": "initial"}))
2214 .await
2215 .unwrap();
2216 let id = row.id.clone();
2217
2218 let task_count = 4_usize;
2219 let updates_per_task = 50_usize;
2220
2221 let handles: Vec<_> = (0..task_count)
2222 .map(|task_id| {
2223 let s = Arc::clone(&store);
2224 let row_id = id.clone();
2225 tokio::spawn(async move {
2226 for i in 0..updates_per_task {
2227 s.update(
2228 &row_id,
2229 serde_json::json!({"title": format!("task-{task_id}-update-{i}")}),
2230 UpdateMode::Replace,
2231 )
2232 .await
2233 .expect("concurrent update must succeed");
2234 }
2235 })
2236 })
2237 .collect();
2238
2239 futures::future::join_all(handles)
2240 .await
2241 .into_iter()
2242 .for_each(|r| r.expect("task must not panic"));
2243
2244 let rows = store.list(None, None, None, None).await.unwrap();
2246 assert_eq!(rows.len(), 1, "update must not insert extra rows");
2247 assert!(
2248 rows[0].data["title"].is_string(),
2249 "title must be a string after concurrent updates"
2250 );
2251 }
2252
2253 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2258 async fn test_store_mutex_poison_propagated_as_error() {
2259 let store = Arc::new(make_test_store().await);
2260
2261 let conn = store.conn.clone();
2263 let _ = tokio::task::spawn_blocking(move || {
2264 let _guard = conn.lock().unwrap(); panic!("intentional poison"); })
2267 .await; let err = store.get("any-id").await.unwrap_err();
2271 assert!(
2272 matches!(&err, MiniAppError::Schema(msg) if msg.contains("mutex poisoned")),
2273 "expected Schema(\"mutex poisoned\"), got: {err:?}"
2274 );
2275 }
2276
2277 #[tokio::test]
2281 async fn store_open_sets_wal_journal_mode() {
2282 let tmp = tempfile::tempdir().expect("tempdir");
2283 let db_path = tmp.path().join("test.db");
2284
2285 let schema = SchemaConfig {
2286 table: "test".into(),
2287 title: None,
2288 description: None,
2289 fields: vec![FieldDef {
2290 name: "title".into(),
2291 ty: FieldType::String,
2292 required: false,
2293 description: None,
2294 }],
2295 dump: None,
2296 history: Default::default(),
2297 };
2298 let store = Store::open(&db_path, schema)
2299 .await
2300 .expect("Store::open should succeed");
2301
2302 let mode = {
2304 let conn = store.conn.lock().expect("lock");
2305 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
2306 .expect("PRAGMA journal_mode query")
2307 };
2308 assert_eq!(
2309 mode.to_lowercase(),
2310 "wal",
2311 "Store::open must set journal_mode = WAL for dual-registry safety (Crux #1)"
2312 );
2313 }
2314
2315 fn make_schema(fields: Vec<FieldDef>) -> SchemaConfig {
2319 SchemaConfig {
2320 table: "test".into(),
2321 title: None,
2322 description: None,
2323 fields,
2324 dump: None,
2325 history: Default::default(),
2326 }
2327 }
2328
2329 #[test]
2331 fn shallow_merge_preserves_absent_fields() {
2332 let schema = make_schema(vec![
2333 FieldDef {
2334 name: "a".into(),
2335 ty: FieldType::Number,
2336 required: true,
2337 description: None,
2338 },
2339 FieldDef {
2340 name: "b".into(),
2341 ty: FieldType::Number,
2342 required: false,
2343 description: None,
2344 },
2345 ]);
2346 let current = serde_json::json!({"a": 1, "b": 2});
2347 let patch = serde_json::json!({"a": 9});
2348 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
2349 assert_eq!(merged["a"], 9, "patched field must be updated");
2350 assert_eq!(
2351 merged["b"], 2,
2352 "absent patch field must be preserved from current"
2353 );
2354 }
2355
2356 #[test]
2358 fn shallow_merge_deletes_null_for_optional_field() {
2359 let schema = make_schema(vec![
2360 FieldDef {
2361 name: "a".into(),
2362 ty: FieldType::Number,
2363 required: true,
2364 description: None,
2365 },
2366 FieldDef {
2367 name: "b".into(),
2368 ty: FieldType::Number,
2369 required: false,
2370 description: None,
2371 },
2372 ]);
2373 let current = serde_json::json!({"a": 1, "b": 2});
2374 let patch = serde_json::json!({"b": null});
2375 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
2376 assert_eq!(merged["a"], 1);
2377 assert!(
2378 merged.get("b").is_none(),
2379 "null-patched optional field must be physically removed (not set to null)"
2380 );
2381 }
2382
2383 #[test]
2385 fn shallow_merge_errors_on_null_for_required_field() {
2386 let schema = make_schema(vec![FieldDef {
2387 name: "title".into(),
2388 ty: FieldType::String,
2389 required: true,
2390 description: None,
2391 }]);
2392 let current = serde_json::json!({"title": "hello"});
2393 let patch = serde_json::json!({"title": null});
2394 let err = shallow_merge(current, patch, &schema).expect_err("must error");
2395 match err {
2396 MiniAppError::Validation { field, reason } => {
2397 assert_eq!(field, "title");
2398 assert!(
2399 reason.contains("required field cannot be deleted via null"),
2400 "unexpected reason: {reason}"
2401 );
2402 }
2403 other => panic!("expected Validation error, got: {other:?}"),
2404 }
2405 }
2406
2407 #[test]
2409 fn shallow_merge_replaces_nested_object_wholesale() {
2410 let schema = make_schema(vec![FieldDef {
2411 name: "cfg".into(),
2412 ty: FieldType::Object,
2413 required: false,
2414 description: None,
2415 }]);
2416 let current = serde_json::json!({"cfg": {"x": 1, "y": 2}});
2417 let patch = serde_json::json!({"cfg": {"x": 9}});
2418 let merged = shallow_merge(current, patch, &schema).expect("merge ok");
2419 assert_eq!(merged["cfg"]["x"], 9, "x must be updated");
2420 assert!(
2421 merged["cfg"].get("y").is_none(),
2422 "y must be absent (nested object replaced wholesale, not deep-merged)"
2423 );
2424 }
2425
2426 #[test]
2428 fn shallow_merge_rejects_non_object_patch() {
2429 let schema = make_schema(vec![]);
2430 let current = serde_json::json!({"a": 1});
2431
2432 for bad_patch in [
2433 serde_json::json!([1, 2, 3]),
2434 serde_json::json!(42),
2435 serde_json::json!("string"),
2436 ] {
2437 let err = shallow_merge(current.clone(), bad_patch, &schema)
2438 .expect_err("non-object patch must be rejected");
2439 match err {
2440 MiniAppError::Validation { field, .. } => {
2441 assert_eq!(field, "(root)", "error field must be '(root)'");
2442 }
2443 other => panic!("expected Validation error, got: {other:?}"),
2444 }
2445 }
2446 }
2447
2448 #[tokio::test]
2451 async fn store_update_merge_runs_post_merge_validation() {
2452 let store = make_test_store().await;
2453 let row = store
2454 .create(serde_json::json!({"title": "x", "state": "open"}))
2455 .await
2456 .unwrap();
2457
2458 let err = store
2460 .update(&row.id, serde_json::json!({"state": 42}), UpdateMode::Merge)
2461 .await
2462 .expect_err("type mismatch must fail post-merge validation");
2463
2464 assert!(
2465 matches!(err, MiniAppError::Validation { .. }),
2466 "expected Validation error, got: {err:?}"
2467 );
2468 }
2469
2470 use crate::filter::ListFilter;
2475
2476 fn make_filter() -> ListFilter {
2478 ListFilter::Eq {
2479 field: "state".to_string(),
2480 value: serde_json::json!("open"),
2481 }
2482 }
2483
2484 #[tokio::test]
2486 async fn alias_create_and_get_round_trip() {
2487 let store = make_test_store().await;
2488 let filter = make_filter();
2489 let filter_json = serde_json::to_string(&filter).unwrap();
2490
2491 store
2492 .alias_create(
2493 "recent_open",
2494 &filter_json,
2495 Some(20),
2496 Some("desc".to_string()),
2497 None,
2498 )
2499 .await
2500 .expect("alias_create must succeed");
2501
2502 let record = store
2503 .alias_get("recent_open")
2504 .await
2505 .expect("alias_get must succeed");
2506
2507 assert_eq!(record.name, "recent_open");
2508 assert_eq!(record.default_limit, Some(20));
2509 assert_eq!(record.description.as_deref(), Some("desc"));
2510
2511 let restored: ListFilter =
2513 serde_json::from_str(&record.filter).expect("filter must deserialise");
2514 let stored_back = serde_json::to_string(&filter).unwrap();
2515 let stored_back2 = serde_json::to_string(&restored).unwrap();
2516 assert_eq!(
2517 stored_back, stored_back2,
2518 "filter must survive a JSON round-trip"
2519 );
2520 }
2521
2522 #[tokio::test]
2524 async fn alias_create_with_optional_nulls() {
2525 let store = make_test_store().await;
2526 let filter = make_filter();
2527 let filter_json = serde_json::to_string(&filter).unwrap();
2528
2529 store
2530 .alias_create("no_opts", &filter_json, None, None, None)
2531 .await
2532 .expect("alias_create must succeed with None optionals");
2533
2534 let record = store
2535 .alias_get("no_opts")
2536 .await
2537 .expect("alias_get must succeed");
2538 assert_eq!(record.name, "no_opts");
2539 assert!(record.default_limit.is_none());
2540 assert!(record.description.is_none());
2541 }
2542
2543 #[tokio::test]
2545 async fn alias_list_returns_all() {
2546 let store = make_test_store().await;
2547 let filter = make_filter();
2548
2549 let list = store
2551 .alias_list()
2552 .await
2553 .expect("alias_list must succeed on empty store");
2554 assert!(list.is_empty(), "empty store should return empty list");
2555
2556 let filter_json = serde_json::to_string(&filter).unwrap();
2557 store
2558 .alias_create("b_alias", &filter_json, None, None, None)
2559 .await
2560 .unwrap();
2561 store
2562 .alias_create("a_alias", &filter_json, None, None, None)
2563 .await
2564 .unwrap();
2565
2566 let list = store.alias_list().await.expect("alias_list must succeed");
2567 assert_eq!(list.len(), 2, "must return 2 aliases");
2568 assert_eq!(list[0].name, "a_alias");
2570 assert_eq!(list[1].name, "b_alias");
2571 }
2572
2573 #[tokio::test]
2575 async fn alias_delete_removes_alias() {
2576 let store = make_test_store().await;
2577 let filter = make_filter();
2578 let filter_json = serde_json::to_string(&filter).unwrap();
2579
2580 store
2581 .alias_create("to_delete", &filter_json, None, None, None)
2582 .await
2583 .unwrap();
2584
2585 store
2586 .alias_delete("to_delete")
2587 .await
2588 .expect("alias_delete must succeed");
2589
2590 let err = store
2591 .alias_get("to_delete")
2592 .await
2593 .expect_err("alias_get after delete must fail");
2594
2595 assert!(
2596 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "to_delete"),
2597 "expected AliasNotFound, got: {err:?}"
2598 );
2599 }
2600
2601 #[tokio::test]
2603 async fn alias_create_duplicate_returns_already_exists() {
2604 let store = make_test_store().await;
2605 let filter = make_filter();
2606 let filter_json = serde_json::to_string(&filter).unwrap();
2607
2608 store
2609 .alias_create("dup", &filter_json, None, None, None)
2610 .await
2611 .expect("first alias_create must succeed");
2612
2613 let err = store
2614 .alias_create("dup", &filter_json, None, None, None)
2615 .await
2616 .expect_err("second alias_create must fail");
2617
2618 assert!(
2619 matches!(err, MiniAppError::AliasAlreadyExists { ref name } if name == "dup"),
2620 "expected AliasAlreadyExists, got: {err:?}"
2621 );
2622 }
2623
2624 #[tokio::test]
2626 async fn alias_get_missing_returns_not_found() {
2627 let store = make_test_store().await;
2628
2629 let err = store
2630 .alias_get("nonexistent")
2631 .await
2632 .expect_err("alias_get on missing alias must fail");
2633
2634 assert!(
2635 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
2636 "expected AliasNotFound, got: {err:?}"
2637 );
2638 }
2639
2640 #[tokio::test]
2642 async fn alias_delete_missing_returns_not_found() {
2643 let store = make_test_store().await;
2644
2645 let err = store
2646 .alias_delete("nonexistent")
2647 .await
2648 .expect_err("alias_delete on missing alias must fail");
2649
2650 assert!(
2651 matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
2652 "expected AliasNotFound, got: {err:?}"
2653 );
2654 }
2655
2656 #[tokio::test]
2659 async fn alias_namespace_isolation_between_stores() {
2660 let store_a = make_test_store().await;
2661 let store_b = make_test_store().await;
2662 let filter = make_filter();
2663
2664 let filter_json = serde_json::to_string(&filter).unwrap();
2665 store_a
2666 .alias_create("shared_name", &filter_json, None, None, None)
2667 .await
2668 .expect("store_a alias_create must succeed");
2669
2670 let err = store_b
2672 .alias_get("shared_name")
2673 .await
2674 .expect_err("alias created in store_a must not be visible in store_b");
2675
2676 assert!(
2677 matches!(err, MiniAppError::AliasNotFound { .. }),
2678 "expected AliasNotFound in store_b, got: {err:?}"
2679 );
2680 }
2681
2682 #[tokio::test]
2686 async fn test_get_prefix_match_single() {
2687 let store = make_test_store().await;
2688 let row = store
2689 .create(serde_json::json!({"title": "prefix-test"}))
2690 .await
2691 .unwrap();
2692 let prefix = &row.id[..8];
2694 let fetched = store.get(prefix).await.unwrap();
2695 assert_eq!(fetched.id, row.id);
2696 assert_eq!(fetched.data["title"], "prefix-test");
2697 }
2698
2699 #[tokio::test]
2701 async fn test_get_prefix_match_not_found() {
2702 let store = make_test_store().await;
2703 let err = store.get("zzzzzzzz").await.unwrap_err();
2705 assert!(
2706 matches!(err, MiniAppError::NotFound { .. }),
2707 "expected NotFound, got: {err:?}"
2708 );
2709 }
2710
2711 #[tokio::test]
2713 async fn test_get_prefix_match_ambiguous() {
2714 let store = make_test_store().await;
2715 let id1 = "aaaaaaaa-0000-4000-8000-000000000001".to_string();
2718 let id2 = "aaaaaaaa-0000-4000-8000-000000000002".to_string();
2719 let id1_clone = id1.clone();
2720 let id2_clone = id2.clone();
2721 store
2722 .execute_under_savepoint(move |sp| {
2723 sp.execute(
2724 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2725 rusqlite::params![id1_clone, r#"{"title":"a1"}"#],
2726 )?;
2727 sp.execute(
2728 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2729 rusqlite::params![id2_clone, r#"{"title":"a2"}"#],
2730 )?;
2731 Ok(())
2732 })
2733 .await
2734 .unwrap();
2735
2736 let err = store.get("aaaaaaaa").await.unwrap_err();
2737 match err {
2738 MiniAppError::AmbiguousId {
2739 ref id_prefix,
2740 ref candidates,
2741 } => {
2742 assert_eq!(id_prefix, "aaaaaaaa");
2743 assert_eq!(candidates.len(), 2);
2744 let mut sorted = candidates.clone();
2745 sorted.sort();
2746 assert_eq!(sorted[0], id1);
2747 assert_eq!(sorted[1], id2);
2748 }
2749 other => panic!("expected AmbiguousId, got: {other:?}"),
2750 }
2751 }
2752
2753 #[tokio::test]
2755 async fn test_get_full_uuid_bypass() {
2756 let store = make_test_store().await;
2757 let row = store
2758 .create(serde_json::json!({"title": "bypass-test"}))
2759 .await
2760 .unwrap();
2761 assert_eq!(row.id.len(), 36, "UUID must be 36 chars");
2762 let fetched = store.get(&row.id).await.unwrap();
2764 assert_eq!(fetched.id, row.id);
2765 }
2766
2767 #[tokio::test]
2769 async fn test_update_prefix_match_single() {
2770 let store = make_test_store().await;
2771 let row = store
2772 .create(serde_json::json!({"title": "before"}))
2773 .await
2774 .unwrap();
2775 let prefix = &row.id[..8];
2776 let updated = store
2777 .update(
2778 prefix,
2779 serde_json::json!({"title": "after"}),
2780 UpdateMode::Replace,
2781 )
2782 .await
2783 .unwrap();
2784 assert_eq!(updated.id, row.id);
2785 assert_eq!(updated.data["title"], "after");
2786 }
2787
2788 #[tokio::test]
2790 async fn test_update_prefix_match_ambiguous() {
2791 let store = make_test_store().await;
2792 let id1 = "bbbbbbbb-0000-4000-8000-000000000001".to_string();
2793 let id2 = "bbbbbbbb-0000-4000-8000-000000000002".to_string();
2794 store
2795 .execute_under_savepoint(move |sp| {
2796 sp.execute(
2797 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2798 rusqlite::params![id1, r#"{"title":"b1"}"#],
2799 )?;
2800 sp.execute(
2801 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2802 rusqlite::params![id2, r#"{"title":"b2"}"#],
2803 )?;
2804 Ok(())
2805 })
2806 .await
2807 .unwrap();
2808
2809 let err = store
2810 .update(
2811 "bbbbbbbb",
2812 serde_json::json!({"title": "x"}),
2813 UpdateMode::Replace,
2814 )
2815 .await
2816 .unwrap_err();
2817 assert!(
2818 matches!(err, MiniAppError::AmbiguousId { .. }),
2819 "expected AmbiguousId, got: {err:?}"
2820 );
2821 }
2822
2823 #[tokio::test]
2825 async fn test_delete_prefix_match_single() {
2826 let store = make_test_store().await;
2827 let row = store
2828 .create(serde_json::json!({"title": "to-delete-prefix"}))
2829 .await
2830 .unwrap();
2831 let prefix = &row.id[..8];
2832 store.delete(prefix).await.unwrap();
2833 let err = store.get(&row.id).await.unwrap_err();
2835 assert!(
2836 matches!(err, MiniAppError::NotFound { .. }),
2837 "expected NotFound after delete, got: {err:?}"
2838 );
2839 }
2840
2841 #[tokio::test]
2843 async fn test_delete_prefix_match_ambiguous() {
2844 let store = make_test_store().await;
2845 let id1 = "cccccccc-0000-4000-8000-000000000001".to_string();
2846 let id2 = "cccccccc-0000-4000-8000-000000000002".to_string();
2847 store
2848 .execute_under_savepoint(move |sp| {
2849 sp.execute(
2850 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2851 rusqlite::params![id1, r#"{"title":"c1"}"#],
2852 )?;
2853 sp.execute(
2854 "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2855 rusqlite::params![id2, r#"{"title":"c2"}"#],
2856 )?;
2857 Ok(())
2858 })
2859 .await
2860 .unwrap();
2861
2862 let err = store.delete("cccccccc").await.unwrap_err();
2863 assert!(
2864 matches!(err, MiniAppError::AmbiguousId { .. }),
2865 "expected AmbiguousId, got: {err:?}"
2866 );
2867 }
2868}