1use std::sync::Arc;
4
5use async_trait::async_trait;
6use uuid::Uuid;
7
8use khive_storage::entity::{Entity, EntityFilter};
9use khive_storage::error::StorageError;
10use khive_storage::types::{
11 BatchWriteSummary, DeleteMode, Page, PageRequest, SqlStatement, SqlValue,
12};
13use khive_storage::EntityStore;
14use khive_storage::StorageCapability;
15
16use crate::error::SqliteError;
17use crate::pool::ConnectionPool;
18use crate::sql_bridge::bind_params;
19use crate::writer_task::WriterTaskHandle;
20
21fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
22 StorageError::driver(StorageCapability::Entities, op, e)
23}
24
25fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
26 StorageError::driver(StorageCapability::Entities, op, e)
27}
28
29pub fn entity_upsert_statement(entity: &Entity) -> SqlStatement {
45 let properties_str = entity
46 .properties
47 .as_ref()
48 .map(|v| serde_json::to_string(v).unwrap_or_default());
49 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
50 SqlStatement {
51 sql: "INSERT OR REPLACE INTO entities \
52 (id, namespace, kind, entity_type, name, description, properties, tags, \
53 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref) \
54 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"
55 .to_string(),
56 params: vec![
57 SqlValue::Text(entity.id.to_string()),
58 SqlValue::Text(entity.namespace.clone()),
59 SqlValue::Text(entity.kind.clone()),
60 match &entity.entity_type {
61 Some(t) => SqlValue::Text(t.clone()),
62 None => SqlValue::Null,
63 },
64 SqlValue::Text(entity.name.clone()),
65 match &entity.description {
66 Some(d) => SqlValue::Text(d.clone()),
67 None => SqlValue::Null,
68 },
69 match properties_str {
70 Some(p) => SqlValue::Text(p),
71 None => SqlValue::Null,
72 },
73 SqlValue::Text(tags_str),
74 SqlValue::Integer(entity.created_at),
75 SqlValue::Integer(entity.updated_at),
76 match entity.deleted_at {
77 Some(d) => SqlValue::Integer(d),
78 None => SqlValue::Null,
79 },
80 match entity.merged_into {
81 Some(u) => SqlValue::Text(u.to_string()),
82 None => SqlValue::Null,
83 },
84 match entity.merge_event_id {
85 Some(u) => SqlValue::Text(u.to_string()),
86 None => SqlValue::Null,
87 },
88 match &entity.content_ref {
89 Some(c) => SqlValue::Text(c.clone()),
90 None => SqlValue::Null,
91 },
92 ],
93 label: Some("entity-upsert".to_string()),
94 }
95}
96
97pub fn entity_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
99 SqlStatement {
100 sql: "UPDATE entities SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL".to_string(),
101 params: vec![
102 SqlValue::Integer(deleted_at),
103 SqlValue::Text(id.to_string()),
104 ],
105 label: Some("entity-delete-soft".to_string()),
106 }
107}
108
109pub fn entity_hard_delete_statement(id: Uuid) -> SqlStatement {
112 SqlStatement {
113 sql: "DELETE FROM entities WHERE id = ?1".to_string(),
114 params: vec![SqlValue::Text(id.to_string())],
115 label: Some("entity-delete-hard".to_string()),
116 }
117}
118
119pub struct SqlEntityStore {
124 pool: Arc<ConnectionPool>,
125 is_file_backed: bool,
126 writer_task: Option<WriterTaskHandle>,
127}
128
129impl SqlEntityStore {
130 pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
147 let writer_task = pool.writer_task_handle().ok().flatten();
153
154 Self {
155 pool,
156 is_file_backed,
157 writer_task,
158 }
159 }
160
161 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
162 let config = self.pool.config();
163 let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
164 operation: "entity_reader".into(),
165 message: "in-memory databases do not support standalone connections".into(),
166 })?;
167
168 let conn = rusqlite::Connection::open_with_flags(
169 path,
170 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
171 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
172 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
173 )
174 .map_err(|e| map_err(e, "open_entity_reader"))?;
175
176 conn.busy_timeout(config.busy_timeout)
177 .map_err(|e| map_err(e, "open_entity_reader"))?;
178 conn.pragma_update(None, "foreign_keys", "ON")
179 .map_err(|e| map_err(e, "open_entity_reader"))?;
180 conn.pragma_update(None, "synchronous", "NORMAL")
181 .map_err(|e| map_err(e, "open_entity_reader"))?;
182
183 Ok(conn)
184 }
185
186 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
203 where
204 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
205 R: Send + 'static,
206 {
207 if let Some(writer_task) = &self.writer_task {
208 return writer_task
209 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
210 .await;
211 }
212
213 let pool = Arc::clone(&self.pool);
214 tokio::task::spawn_blocking(move || {
215 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
216 f(guard.conn()).map_err(|e| map_err(e, op))
217 })
218 .await
219 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
220 }
221
222 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
223 where
224 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
225 R: Send + 'static,
226 {
227 if self.is_file_backed {
228 let conn = self.open_standalone_reader()?;
229 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
230 .await
231 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
232 } else {
233 let pool = Arc::clone(&self.pool);
234 tokio::task::spawn_blocking(move || {
235 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
236 f(guard.conn()).map_err(|e| map_err(e, op))
237 })
238 .await
239 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
240 }
241 }
242}
243
244fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
249 let id_str: String = row.get(0)?;
250 let namespace: String = row.get(1)?;
251 let kind: String = row.get(2)?;
252 let entity_type: Option<String> = row.get(3)?;
253 let name: String = row.get(4)?;
254 let description: Option<String> = row.get(5)?;
255 let properties_str: Option<String> = row.get(6)?;
256 let tags_str: String = row.get(7)?;
257 let created_at: i64 = row.get(8)?;
258 let updated_at: i64 = row.get(9)?;
259 let deleted_at: Option<i64> = row.get(10)?;
260 let merged_into_str: Option<String> = row.get(11)?;
261 let merge_event_id_str: Option<String> = row.get(12)?;
262 let content_ref: Option<String> = row.get(13)?;
263
264 let id = parse_uuid(&id_str)?;
265
266 let properties = properties_str
267 .map(|s| {
268 serde_json::from_str(&s).map_err(|e| {
269 rusqlite::Error::FromSqlConversionFailure(
270 6,
271 rusqlite::types::Type::Text,
272 Box::new(e),
273 )
274 })
275 })
276 .transpose()?;
277
278 let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
279 rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
280 })?;
281
282 let merged_into = merged_into_str
283 .as_deref()
284 .map(Uuid::parse_str)
285 .transpose()
286 .map_err(|e| {
287 rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
288 })?;
289
290 let merge_event_id = merge_event_id_str
291 .as_deref()
292 .map(Uuid::parse_str)
293 .transpose()
294 .map_err(|e| {
295 rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
296 })?;
297
298 Ok(Entity {
299 id,
300 namespace,
301 kind,
302 entity_type,
303 name,
304 description,
305 properties,
306 tags,
307 created_at,
308 updated_at,
309 deleted_at,
310 merged_into,
311 merge_event_id,
312 content_ref,
313 })
314}
315
316fn batch_upsert_entities(
326 conn: &rusqlite::Connection,
327 entities: &[Entity],
328 attempted: u64,
329) -> Result<BatchWriteSummary, rusqlite::Error> {
330 let mut affected = 0u64;
331 let mut failed = 0u64;
332 let mut first_error = String::new();
333
334 for entity in entities {
335 let id_str = entity.id.to_string();
336 let properties_str = entity
337 .properties
338 .as_ref()
339 .map(|v| serde_json::to_string(v).unwrap_or_default());
340 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
341
342 let merged_into_str = entity.merged_into.map(|u| u.to_string());
343 let merge_event_id_str = entity.merge_event_id.map(|u| u.to_string());
344 match conn.execute(
345 "INSERT OR REPLACE INTO entities \
346 (id, namespace, kind, entity_type, name, description, properties, tags, \
347 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref) \
348 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
349 rusqlite::params![
350 id_str,
351 &entity.namespace,
352 entity.kind,
353 entity.entity_type,
354 entity.name,
355 entity.description,
356 properties_str,
357 tags_str,
358 entity.created_at,
359 entity.updated_at,
360 entity.deleted_at,
361 merged_into_str,
362 merge_event_id_str,
363 entity.content_ref,
364 ],
365 ) {
366 Ok(_) => affected += 1,
367 Err(e) => {
368 if first_error.is_empty() {
369 first_error = e.to_string();
370 }
371 failed += 1;
372 }
373 }
374 }
375
376 Ok(BatchWriteSummary {
377 attempted,
378 affected,
379 failed,
380 first_error,
381 })
382}
383
384fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
385 Uuid::parse_str(s).map_err(|e| {
386 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
387 })
388}
389
390fn escape_like(input: &str) -> String {
396 let mut out = String::with_capacity(input.len());
397 for c in input.chars() {
398 if matches!(c, '\\' | '%' | '_') {
399 out.push('\\');
400 }
401 out.push(c);
402 }
403 out
404}
405
406fn build_entity_where(
407 namespace: &str,
408 filter: &EntityFilter,
409) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
410 let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
414 if !filter.namespaces.is_empty() {
415 let placeholders: Vec<String> = (1..=filter.namespaces.len())
416 .map(|i| format!("?{i}"))
417 .collect();
418 let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
419 .namespaces
420 .iter()
421 .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
422 .collect();
423 (
424 format!("namespace IN ({})", placeholders.join(", ")),
425 params,
426 )
427 } else {
428 (
429 "namespace = ?1".to_string(),
430 vec![Box::new(namespace.to_string())],
431 )
432 };
433
434 let mut conditions: Vec<String> = vec![ns_condition, "deleted_at IS NULL".to_string()];
435 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
436
437 if !filter.ids.is_empty() {
438 let placeholders: Vec<String> = filter
439 .ids
440 .iter()
441 .map(|id| {
442 params.push(Box::new(id.to_string()));
443 format!("?{}", params.len())
444 })
445 .collect();
446 conditions.push(format!("id IN ({})", placeholders.join(", ")));
447 }
448
449 if !filter.kinds.is_empty() {
450 let placeholders: Vec<String> = filter
451 .kinds
452 .iter()
453 .map(|k| {
454 params.push(Box::new(k.clone()));
455 format!("?{}", params.len())
456 })
457 .collect();
458 conditions.push(format!("kind IN ({})", placeholders.join(", ")));
459 }
460
461 if !filter.entity_types.is_empty() {
462 let placeholders: Vec<String> = filter
463 .entity_types
464 .iter()
465 .map(|t| {
466 params.push(Box::new(t.clone()));
467 format!("?{}", params.len())
468 })
469 .collect();
470 conditions.push(format!("entity_type IN ({})", placeholders.join(", ")));
471 }
472
473 if let Some(ref prefix) = filter.name_prefix {
474 params.push(Box::new(format!("{}%", escape_like(prefix))));
475 conditions.push(format!("name LIKE ?{} ESCAPE '\\'", params.len()));
476 }
477
478 if let Some(ref exact) = filter.name_exact {
479 params.push(Box::new(exact.clone()));
480 conditions.push(format!("name = ?{} COLLATE BINARY", params.len()));
485 }
486
487 if !filter.names_ci.is_empty() {
488 let placeholders: Vec<String> = filter
491 .names_ci
492 .iter()
493 .map(|n| {
494 params.push(Box::new(n.to_ascii_lowercase()));
495 format!("?{}", params.len())
496 })
497 .collect();
498 conditions.push(format!("LOWER(name) IN ({})", placeholders.join(", ")));
499 }
500
501 if !filter.tags_any.is_empty() {
502 let placeholders: Vec<String> = filter
503 .tags_any
504 .iter()
505 .map(|t| {
506 params.push(Box::new(t.to_lowercase()));
509 format!("?{}", params.len())
510 })
511 .collect();
512 conditions.push(format!(
513 "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
514 placeholders.join(", ")
515 ));
516 }
517
518 let clause = format!(" WHERE {}", conditions.join(" AND "));
519 (clause, params)
520}
521
522fn build_candidate_entity_query(
523 columns: &str,
524 where_sql: &str,
525 candidate_param_indices: &[usize],
526 order_by: &str,
527 limit_idx: usize,
528 offset_idx: usize,
529) -> String {
530 let candidate_rows = candidate_param_indices
531 .iter()
532 .map(|idx| format!("(?{idx})"))
533 .collect::<Vec<_>>()
534 .join(", ");
535
536 format!(
537 "WITH candidates(folded_name) AS (VALUES {candidate_rows}), \
538 matched_entities(entity_id) AS (\
539 SELECT (\
540 SELECT id FROM entities{where_sql} \
541 AND LOWER(name) = candidates.folded_name LIMIT 1\
542 ) FROM candidates\
543 ) \
544 SELECT {columns} FROM entities \
545 JOIN matched_entities ON entities.id = matched_entities.entity_id \
546 ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
547 )
548}
549
550#[async_trait]
555impl EntityStore for SqlEntityStore {
556 async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
557 let statement = entity_upsert_statement(&entity);
558 self.with_writer("upsert_entity", move |conn| {
559 let mut stmt = conn.prepare(&statement.sql)?;
560 bind_params(&mut stmt, &statement.params)?;
561 stmt.raw_execute()?;
562 Ok(())
563 })
564 .await
565 }
566
567 async fn upsert_entities(
568 &self,
569 entities: Vec<Entity>,
570 ) -> Result<BatchWriteSummary, StorageError> {
571 let attempted = entities.len() as u64;
572
573 if let Some(writer_task) = &self.writer_task {
580 return writer_task
581 .send(move |conn| {
582 batch_upsert_entities(conn, &entities, attempted)
583 .map_err(|e| map_err(e, "upsert_entities"))
584 })
585 .await;
586 }
587
588 self.with_writer("upsert_entities", move |conn| {
592 conn.execute_batch("BEGIN IMMEDIATE")?;
593 let _tx_handle =
594 khive_storage::tx_registry::register(Some("entity_upsert_batch".to_string()));
595
596 let summary = batch_upsert_entities(conn, &entities, attempted)?;
597
598 if let Err(e) = conn.execute_batch("COMMIT") {
599 let _ = conn.execute_batch("ROLLBACK");
600 return Err(e);
601 }
602 Ok(summary)
603 })
604 .await
605 }
606
607 async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
608 let id_str = id.to_string();
609
610 self.with_reader("get_entity", move |conn| {
611 let mut stmt = conn.prepare(
612 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
613 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref \
614 FROM entities WHERE id = ?1 AND deleted_at IS NULL",
615 )?;
616 let mut rows = stmt.query(rusqlite::params![id_str])?;
617 match rows.next()? {
618 Some(row) => Ok(Some(read_entity(row)?)),
619 None => Ok(None),
620 }
621 })
622 .await
623 }
624
625 async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
626 match mode {
627 DeleteMode::Soft => {
628 let now = chrono::Utc::now().timestamp_micros();
629 let statement = entity_soft_delete_statement(id, now);
630 self.with_writer("delete_entity_soft", move |conn| {
631 let mut stmt = conn.prepare(&statement.sql)?;
632 bind_params(&mut stmt, &statement.params)?;
633 Ok(stmt.raw_execute()? > 0)
634 })
635 .await
636 }
637 DeleteMode::Hard => {
638 let statement = entity_hard_delete_statement(id);
639 self.with_writer("delete_entity_hard", move |conn| {
640 let mut stmt = conn.prepare(&statement.sql)?;
641 bind_params(&mut stmt, &statement.params)?;
642 Ok(stmt.raw_execute()? > 0)
643 })
644 .await
645 }
646 }
647 }
648
649 async fn query_entities(
650 &self,
651 namespace: &str,
652 filter: EntityFilter,
653 page: PageRequest,
654 ) -> Result<Page<Entity>, StorageError> {
655 let namespace = namespace.to_string();
656 let limit_i64 = i64::from(page.limit);
657 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
658 capability: StorageCapability::Entities,
659 operation: "query_entities".into(),
660 message: format!(
661 "PageRequest: offset must be <= i64::MAX, got {}",
662 page.offset
663 ),
664 })?;
665
666 self.with_reader("query_entities", move |conn| {
667 let total = if filter.names_ci.is_empty() {
668 let (count_sql, count_params) = build_entity_where(&namespace, &filter);
669 let sql = format!("SELECT COUNT(*) FROM entities{count_sql}");
670 let mut stmt = conn.prepare(&sql)?;
671 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
672 count_params.iter().map(|p| p.as_ref()).collect();
673 Some(stmt.query_row(param_refs.as_slice(), |row| row.get::<_, i64>(0))? as u64)
674 } else {
675 None
676 };
677
678 let mut lookup_filter = filter.clone();
679 lookup_filter.names_ci.clear();
680 let effective_filter = if filter.names_ci.is_empty() {
681 &filter
682 } else {
683 &lookup_filter
684 };
685 let (where_sql, mut data_params) = build_entity_where(&namespace, effective_filter);
686
687 let candidate_param_indices = if filter.names_ci.is_empty() {
688 Vec::new()
689 } else {
690 let mut candidates: Vec<String> = filter
691 .names_ci
692 .iter()
693 .map(|name| name.to_ascii_lowercase())
694 .collect();
695 candidates.sort_unstable();
696 candidates.dedup();
697 candidates
698 .into_iter()
699 .map(|candidate| {
700 data_params.push(Box::new(candidate));
701 data_params.len()
702 })
703 .collect()
704 };
705
706 let order_by = if let Some(ref prefix) = filter.name_prefix {
712 data_params.push(Box::new(prefix.to_ascii_lowercase()));
713 format!(
714 "CASE WHEN LOWER(name) = ?{} THEN 0 ELSE 1 END, created_at DESC",
715 data_params.len()
716 )
717 } else {
718 "created_at DESC".to_string()
719 };
720
721 data_params.push(Box::new(limit_i64));
722 data_params.push(Box::new(offset_i64));
723
724 let limit_idx = data_params.len() - 1;
725 let offset_idx = data_params.len();
726
727 let columns = "id, namespace, kind, entity_type, name, description, properties, tags, \
728 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref";
729 let data_sql = if filter.names_ci.is_empty() {
730 format!(
731 "SELECT {columns} FROM entities{where_sql} \
732 ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
733 )
734 } else {
735 build_candidate_entity_query(
736 columns,
737 &where_sql,
738 &candidate_param_indices,
739 &order_by,
740 limit_idx,
741 offset_idx,
742 )
743 };
744
745 let mut stmt = conn.prepare(&data_sql)?;
746 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
747 data_params.iter().map(|p| p.as_ref()).collect();
748 let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
749
750 let mut items = Vec::new();
751 for row in rows {
752 items.push(row?);
753 }
754
755 Ok(Page { items, total })
756 })
757 .await
758 }
759
760 async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
761 let id_str = id.to_string();
762
763 self.with_reader("get_entity_including_deleted", move |conn| {
764 let mut stmt = conn.prepare(
765 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
766 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref \
767 FROM entities WHERE id = ?1",
768 )?;
769 let mut rows = stmt.query(rusqlite::params![id_str])?;
770 match rows.next()? {
771 Some(row) => Ok(Some(read_entity(row)?)),
772 None => Ok(None),
773 }
774 })
775 .await
776 }
777
778 async fn count_entities(
779 &self,
780 namespace: &str,
781 filter: EntityFilter,
782 ) -> Result<u64, StorageError> {
783 let namespace = namespace.to_string();
784
785 self.with_reader("count_entities", move |conn| {
786 let (where_sql, params) = build_entity_where(&namespace, &filter);
787 let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
788 let mut stmt = conn.prepare(&sql)?;
789 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
790 params.iter().map(|p| p.as_ref()).collect();
791 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
792 Ok(count as u64)
793 })
794 .await
795 }
796}
797
798const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
803
804pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
805 conn.execute_batch(ENTITIES_DDL)
806}
807
808#[cfg(test)]
809#[path = "entity_tests.rs"]
810mod tests;