1use anyhow::{anyhow, Result};
16use base64::Engine;
17use rusqlite::hooks::{Action, PreUpdateCase};
18use rusqlite::types::ValueRef;
19use rusqlite::{Connection, OpenFlags};
20use serde_json::Value;
21use std::collections::{HashMap, HashSet};
22use std::sync::{Arc, Mutex};
23use tokio::sync::{mpsc, oneshot};
24
25pub type JsonRow = serde_json::Map<String, Value>;
26
27#[derive(Debug)]
28pub enum SqliteCommand {
29 Execute {
30 sql: String,
31 response_tx: oneshot::Sender<Result<usize>>,
32 },
33 ExecuteParameterized {
34 sql: String,
35 params: Vec<SqliteParam>,
36 response_tx: oneshot::Sender<Result<usize>>,
37 },
38 ExecuteBatch {
39 sql: String,
40 response_tx: oneshot::Sender<Result<()>>,
41 },
42 QueryRows {
43 sql: String,
44 response_tx: oneshot::Sender<Result<Vec<JsonRow>>>,
45 },
46 QueryRowsParameterized {
47 sql: String,
48 params: Vec<SqliteParam>,
49 response_tx: oneshot::Sender<Result<Vec<JsonRow>>>,
50 },
51 BeginTransaction {
52 response_tx: oneshot::Sender<Result<()>>,
53 },
54 CommitTransaction {
55 response_tx: oneshot::Sender<Result<()>>,
56 },
57 RollbackTransaction {
58 response_tx: oneshot::Sender<Result<()>>,
59 },
60 Shutdown,
61}
62
63#[derive(Debug, Clone)]
65pub enum SqliteParam {
66 Null,
67 Integer(i64),
68 Real(f64),
69 Text(String),
70 Bool(bool),
71}
72
73#[derive(Debug, Clone)]
74pub struct ChangeEvent {
75 pub action: Action,
76 pub table: String,
77 pub old_values: Option<Vec<(String, Value)>>,
78 pub new_values: Option<Vec<(String, Value)>>,
79 pub old_row_id: Option<i64>,
80 pub new_row_id: Option<i64>,
81 pub table_pk_columns: Vec<String>,
82 pub timestamp_ms: u64,
83}
84
85#[derive(Debug, Clone)]
86pub struct SqliteThreadConfig {
87 pub path: Option<String>,
88 pub tables: Option<Vec<String>>,
89}
90
91#[derive(Debug, Default, Clone)]
92struct TableSchema {
93 columns: Vec<String>,
94 pk_columns: Vec<String>,
95}
96
97#[derive(Debug, Default)]
98struct TransactionBuffer {
99 events: Vec<ChangeEvent>,
100 savepoint_markers: Vec<(String, usize)>,
101}
102
103impl TransactionBuffer {
104 fn push(&mut self, event: ChangeEvent) {
105 self.events.push(event);
106 }
107
108 fn begin_savepoint(&mut self, name: String) {
109 self.savepoint_markers.push((name, self.events.len()));
110 }
111
112 fn rollback_to_savepoint(&mut self, name: &str) {
113 if let Some(index) = self
114 .savepoint_markers
115 .iter()
116 .rposition(|(marker_name, _)| marker_name.eq_ignore_ascii_case(name))
117 {
118 let marker = self.savepoint_markers[index].1;
119 self.events.truncate(marker);
120 self.savepoint_markers.truncate(index + 1);
121 }
122 }
123
124 fn release_savepoint(&mut self, name: &str) {
125 if let Some(index) = self
126 .savepoint_markers
127 .iter()
128 .rposition(|(marker_name, _)| marker_name.eq_ignore_ascii_case(name))
129 {
130 self.savepoint_markers.remove(index);
131 }
132 }
133
134 fn take_all(&mut self) -> Vec<ChangeEvent> {
135 self.savepoint_markers.clear();
136 std::mem::take(&mut self.events)
137 }
138
139 fn clear(&mut self) {
140 self.events.clear();
141 self.savepoint_markers.clear();
142 }
143}
144
145#[derive(Debug, Clone)]
146enum SavepointCommand {
147 Savepoint(String),
148 RollbackTo(String),
149 Release(String),
150}
151
152pub fn run_sqlite_thread(
153 config: SqliteThreadConfig,
154 mut command_rx: mpsc::UnboundedReceiver<SqliteCommand>,
155 event_tx: mpsc::UnboundedSender<ChangeEvent>,
156) -> Result<()> {
157 let conn = if let Some(path) = config.path {
158 Connection::open_with_flags(
159 path,
160 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
161 )?
162 } else {
163 Connection::open_in_memory()?
164 };
165
166 let allowed_tables = config
167 .tables
168 .map(|tables| tables.into_iter().collect::<HashSet<_>>());
169 let schema_cache: Arc<Mutex<HashMap<String, TableSchema>>> =
170 Arc::new(Mutex::new(HashMap::new()));
171 let tx_buffer = Arc::new(Mutex::new(TransactionBuffer::default()));
172
173 {
174 let mut cache = schema_cache
175 .lock()
176 .map_err(|_| anyhow!("schema cache poisoned"))?;
177 refresh_schema_cache(&conn, &mut cache, allowed_tables.as_ref())?;
178 }
179
180 let schema_cache_pre = Arc::clone(&schema_cache);
181 let tx_buffer_pre = Arc::clone(&tx_buffer);
182 let allowed_tables_pre = allowed_tables.clone();
183
184 conn.preupdate_hook(Some(
185 move |action: Action, db_name: &str, table_name: &str, case: &PreUpdateCase| {
186 if db_name != "main" {
187 return;
188 }
189
190 if let Some(allowed) = &allowed_tables_pre {
191 if !allowed.contains(table_name) {
192 return;
193 }
194 }
195
196 let schema = schema_cache_pre
197 .lock()
198 .ok()
199 .and_then(|cache| cache.get(table_name).cloned())
200 .unwrap_or_default();
201
202 let mut old_values: Option<Vec<(String, Value)>> = None;
203 let mut new_values: Option<Vec<(String, Value)>> = None;
204 let mut old_row_id = None;
205 let mut new_row_id = None;
206
207 match case {
208 PreUpdateCase::Insert(accessor) => {
209 let mut values = Vec::new();
210 for idx in 0..accessor.get_column_count() {
211 if let Ok(v) = accessor.get_new_column_value(idx) {
212 values.push((column_name(&schema, idx), value_ref_to_json(v)));
213 }
214 }
215 new_values = Some(values);
216 new_row_id = Some(accessor.get_new_row_id());
217 }
218 PreUpdateCase::Delete(accessor) => {
219 let mut values = Vec::new();
220 for idx in 0..accessor.get_column_count() {
221 if let Ok(v) = accessor.get_old_column_value(idx) {
222 values.push((column_name(&schema, idx), value_ref_to_json(v)));
223 }
224 }
225 old_values = Some(values);
226 old_row_id = Some(accessor.get_old_row_id());
227 }
228 PreUpdateCase::Update {
229 old_value_accessor,
230 new_value_accessor,
231 } => {
232 let mut old = Vec::new();
233 let mut new = Vec::new();
234 for idx in 0..old_value_accessor.get_column_count() {
235 let name = column_name(&schema, idx);
236 if let Ok(v) = old_value_accessor.get_old_column_value(idx) {
237 old.push((name.clone(), value_ref_to_json(v)));
238 }
239 if let Ok(v) = new_value_accessor.get_new_column_value(idx) {
240 new.push((name, value_ref_to_json(v)));
241 }
242 }
243 old_values = Some(old);
244 new_values = Some(new);
245 old_row_id = Some(old_value_accessor.get_old_row_id());
246 new_row_id = Some(new_value_accessor.get_new_row_id());
247 }
248 PreUpdateCase::Unknown => {}
249 }
250
251 let event = ChangeEvent {
252 action,
253 table: table_name.to_string(),
254 old_values,
255 new_values,
256 old_row_id,
257 new_row_id,
258 table_pk_columns: schema.pk_columns,
259 timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
260 };
261
262 if let Ok(mut buffer) = tx_buffer_pre.lock() {
263 buffer.push(event);
264 }
265 },
266 ));
267
268 let tx_buffer_commit = Arc::clone(&tx_buffer);
269 let event_tx_commit = event_tx.clone();
270 conn.commit_hook(Some(move || {
271 if let Ok(mut buffer) = tx_buffer_commit.lock() {
272 for event in buffer.take_all() {
273 let _ = event_tx_commit.send(event);
274 }
275 }
276 false
277 }));
278
279 let tx_buffer_rollback = Arc::clone(&tx_buffer);
280 conn.rollback_hook(Some(move || {
281 if let Ok(mut buffer) = tx_buffer_rollback.lock() {
282 buffer.clear();
283 }
284 }));
285
286 while let Some(command) = command_rx.blocking_recv() {
287 match command {
288 SqliteCommand::Execute { sql, response_tx } => {
289 let savepoint_command = parse_savepoint_command(sql.as_str());
290 let result = conn.execute(sql.as_str(), []).map_err(anyhow::Error::from);
291 if result.is_ok() {
292 if let Some(command) = savepoint_command {
293 apply_savepoint_command(&tx_buffer, command);
294 }
295 let _ = refresh_schema_cache_for_runtime(
296 &conn,
297 &schema_cache,
298 allowed_tables.as_ref(),
299 );
300 }
301 let _ = response_tx.send(result);
302 }
303 SqliteCommand::ExecuteParameterized {
304 sql,
305 params,
306 response_tx,
307 } => {
308 let rusqlite_params: Vec<Box<dyn rusqlite::types::ToSql>> =
309 params.iter().map(sqlite_param_to_rusqlite).collect();
310 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
311 rusqlite_params.iter().map(|p| p.as_ref()).collect();
312 let result = conn
313 .execute(sql.as_str(), param_refs.as_slice())
314 .map_err(anyhow::Error::from);
315 if result.is_ok() {
316 let _ = refresh_schema_cache_for_runtime(
317 &conn,
318 &schema_cache,
319 allowed_tables.as_ref(),
320 );
321 }
322 let _ = response_tx.send(result);
323 }
324 SqliteCommand::ExecuteBatch { sql, response_tx } => {
325 let result = conn
326 .execute_batch(sql.as_str())
327 .map_err(anyhow::Error::from);
328 if result.is_ok() {
329 let _ = refresh_schema_cache_for_runtime(
330 &conn,
331 &schema_cache,
332 allowed_tables.as_ref(),
333 );
334 }
335 let _ = response_tx.send(result);
336 }
337 SqliteCommand::QueryRows { sql, response_tx } => {
338 let _ = response_tx.send(query_rows(&conn, sql.as_str(), &[]));
339 }
340 SqliteCommand::QueryRowsParameterized {
341 sql,
342 params,
343 response_tx,
344 } => {
345 let rusqlite_params: Vec<Box<dyn rusqlite::types::ToSql>> =
346 params.iter().map(sqlite_param_to_rusqlite).collect();
347 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
348 rusqlite_params.iter().map(|p| p.as_ref()).collect();
349 let _ = response_tx.send(query_rows(&conn, sql.as_str(), param_refs.as_slice()));
350 }
351 SqliteCommand::BeginTransaction { response_tx } => {
352 let result = conn
353 .execute("BEGIN TRANSACTION", [])
354 .map(|_| ())
355 .map_err(anyhow::Error::from);
356 let _ = response_tx.send(result);
357 }
358 SqliteCommand::CommitTransaction { response_tx } => {
359 let result = conn
360 .execute("COMMIT", [])
361 .map(|_| ())
362 .map_err(anyhow::Error::from);
363 let _ = response_tx.send(result);
364 }
365 SqliteCommand::RollbackTransaction { response_tx } => {
366 let result = conn
367 .execute("ROLLBACK", [])
368 .map(|_| ())
369 .map_err(anyhow::Error::from);
370 let _ = response_tx.send(result);
371 }
372 SqliteCommand::Shutdown => break,
373 }
374 }
375
376 Ok(())
377}
378
379fn refresh_schema_cache_for_runtime(
380 conn: &Connection,
381 schema_cache: &Arc<Mutex<HashMap<String, TableSchema>>>,
382 allowed_tables: Option<&HashSet<String>>,
383) -> Result<()> {
384 let mut cache = schema_cache
385 .lock()
386 .map_err(|_| anyhow!("schema cache poisoned"))?;
387 refresh_schema_cache(conn, &mut cache, allowed_tables)
388}
389
390fn refresh_schema_cache(
391 conn: &Connection,
392 cache: &mut HashMap<String, TableSchema>,
393 allowed_tables: Option<&HashSet<String>>,
394) -> Result<()> {
395 let mut stmt = conn.prepare(
396 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
397 )?;
398 let table_names = stmt
399 .query_map([], |row| row.get::<_, String>(0))?
400 .collect::<std::result::Result<Vec<_>, _>>()?;
401
402 for table in table_names {
403 if let Some(allowed) = allowed_tables {
404 if !allowed.contains(&table) {
405 continue;
406 }
407 }
408 let schema = load_table_schema(conn, &table)?;
409 cache.insert(table, schema);
410 }
411 Ok(())
412}
413
414fn load_table_schema(conn: &Connection, table: &str) -> Result<TableSchema> {
415 let escaped = table.replace('\'', "''");
416 let sql = format!("PRAGMA table_info('{escaped}')");
417 let mut stmt = conn.prepare(sql.as_str())?;
418
419 let mut columns = Vec::new();
420 let mut pk_pairs: Vec<(i64, String)> = Vec::new();
421
422 let mut rows = stmt.query([])?;
423 while let Some(row) = rows.next()? {
424 let name: String = row.get(1)?;
425 let pk_order: i64 = row.get(5)?;
426 columns.push(name.clone());
427 if pk_order > 0 {
428 pk_pairs.push((pk_order, name));
429 }
430 }
431
432 pk_pairs.sort_by_key(|(order, _)| *order);
433 let pk_columns = pk_pairs
434 .into_iter()
435 .map(|(_, name)| name)
436 .collect::<Vec<_>>();
437
438 Ok(TableSchema {
439 columns,
440 pk_columns,
441 })
442}
443
444fn column_name(schema: &TableSchema, index: i32) -> String {
445 schema
446 .columns
447 .get(index as usize)
448 .cloned()
449 .unwrap_or_else(|| {
450 log::warn!(
451 "Column index {index} out of range (schema has {} columns), using fallback name",
452 schema.columns.len()
453 );
454 format!("col_{index}")
455 })
456}
457
458fn value_ref_to_json(value: ValueRef<'_>) -> Value {
459 match value {
460 ValueRef::Null => Value::Null,
461 ValueRef::Integer(v) => Value::Number(v.into()),
462 ValueRef::Real(v) => serde_json::Number::from_f64(v)
463 .map(Value::Number)
464 .unwrap_or(Value::Null),
465 ValueRef::Text(v) => Value::String(String::from_utf8_lossy(v).to_string()),
466 ValueRef::Blob(v) => Value::String(base64::engine::general_purpose::STANDARD.encode(v)),
467 }
468}
469
470fn sqlite_param_to_rusqlite(p: &SqliteParam) -> Box<dyn rusqlite::types::ToSql> {
471 match p {
472 SqliteParam::Null => Box::new(rusqlite::types::Null) as Box<dyn rusqlite::types::ToSql>,
473 SqliteParam::Integer(v) => Box::new(*v),
474 SqliteParam::Real(v) => Box::new(*v),
475 SqliteParam::Text(v) => Box::new(v.clone()),
476 SqliteParam::Bool(v) => Box::new(*v),
477 }
478}
479
480fn query_rows(
481 conn: &Connection,
482 sql: &str,
483 params: &[&dyn rusqlite::types::ToSql],
484) -> Result<Vec<JsonRow>> {
485 let mut stmt = conn.prepare(sql)?;
486 let columns = stmt
487 .column_names()
488 .iter()
489 .map(|name| (*name).to_string())
490 .collect::<Vec<_>>();
491
492 let mut rows = stmt.query(params)?;
493 let mut result = Vec::new();
494 while let Some(row) = rows.next()? {
495 let mut item = serde_json::Map::new();
496 for (index, name) in columns.iter().enumerate() {
497 let value = row.get_ref(index)?;
498 item.insert(name.clone(), value_ref_to_json(value));
499 }
500 result.push(item);
501 }
502 Ok(result)
503}
504
505fn parse_savepoint_command(sql: &str) -> Option<SavepointCommand> {
506 let trimmed = sql.trim().trim_end_matches(';').trim();
507 if trimmed.is_empty() {
508 return None;
509 }
510
511 let lower = trimmed.to_ascii_lowercase();
512 if let Some(name) = lower.strip_prefix("savepoint ") {
513 return extract_identifier(trimmed, name.len()).map(SavepointCommand::Savepoint);
514 }
515 if let Some(name) = lower.strip_prefix("rollback to savepoint ") {
516 return extract_identifier(trimmed, name.len()).map(SavepointCommand::RollbackTo);
517 }
518 if let Some(name) = lower.strip_prefix("rollback to ") {
519 return extract_identifier(trimmed, name.len()).map(SavepointCommand::RollbackTo);
520 }
521 if let Some(name) = lower.strip_prefix("release savepoint ") {
522 return extract_identifier(trimmed, name.len()).map(SavepointCommand::Release);
523 }
524 if let Some(name) = lower.strip_prefix("release ") {
525 return extract_identifier(trimmed, name.len()).map(SavepointCommand::Release);
526 }
527 None
528}
529
530fn extract_identifier(original_sql: &str, suffix_len: usize) -> Option<String> {
531 let sql = original_sql.trim().trim_end_matches(';').trim();
532 let start = sql.len().checked_sub(suffix_len)?;
533 let remainder = sql.get(start..)?.trim();
534 let token = remainder
535 .split_whitespace()
536 .next()
537 .map(|value| value.trim_matches('"').trim_matches('\'').to_string())?;
538 if token.is_empty() {
539 None
540 } else {
541 Some(token)
542 }
543}
544
545fn apply_savepoint_command(tx_buffer: &Arc<Mutex<TransactionBuffer>>, command: SavepointCommand) {
546 if let Ok(mut buffer) = tx_buffer.lock() {
547 match command {
548 SavepointCommand::Savepoint(name) => buffer.begin_savepoint(name),
549 SavepointCommand::RollbackTo(name) => buffer.rollback_to_savepoint(&name),
550 SavepointCommand::Release(name) => buffer.release_savepoint(&name),
551 }
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use tokio::time::{timeout, Duration};
559
560 fn start_thread() -> (
561 mpsc::UnboundedSender<SqliteCommand>,
562 mpsc::UnboundedReceiver<ChangeEvent>,
563 std::thread::JoinHandle<()>,
564 ) {
565 let (command_tx, command_rx) = mpsc::unbounded_channel();
566 let (event_tx, event_rx) = mpsc::unbounded_channel();
567 let join_handle = std::thread::spawn(move || {
568 run_sqlite_thread(
569 SqliteThreadConfig {
570 path: None,
571 tables: None,
572 },
573 command_rx,
574 event_tx,
575 )
576 .expect("sqlite thread failed");
577 });
578 (command_tx, event_rx, join_handle)
579 }
580
581 async fn exec(command_tx: &mpsc::UnboundedSender<SqliteCommand>, sql: &str) -> Result<usize> {
582 let (response_tx, response_rx) = oneshot::channel();
583 command_tx
584 .send(SqliteCommand::Execute {
585 sql: sql.to_string(),
586 response_tx,
587 })
588 .expect("failed to send execute command");
589 response_rx.await.expect("response channel closed")
590 }
591
592 async fn begin_tx(command_tx: &mpsc::UnboundedSender<SqliteCommand>) {
593 let (response_tx, response_rx) = oneshot::channel();
594 command_tx
595 .send(SqliteCommand::BeginTransaction { response_tx })
596 .expect("failed to send begin transaction");
597 response_rx
598 .await
599 .expect("begin response closed")
600 .expect("begin failed");
601 }
602
603 async fn commit_tx(command_tx: &mpsc::UnboundedSender<SqliteCommand>) {
604 let (response_tx, response_rx) = oneshot::channel();
605 command_tx
606 .send(SqliteCommand::CommitTransaction { response_tx })
607 .expect("failed to send commit transaction");
608 response_rx
609 .await
610 .expect("commit response closed")
611 .expect("commit failed");
612 }
613
614 async fn rollback_tx(command_tx: &mpsc::UnboundedSender<SqliteCommand>) {
615 let (response_tx, response_rx) = oneshot::channel();
616 command_tx
617 .send(SqliteCommand::RollbackTransaction { response_tx })
618 .expect("failed to send rollback transaction");
619 response_rx
620 .await
621 .expect("rollback response closed")
622 .expect("rollback failed");
623 }
624
625 #[tokio::test]
626 async fn committed_transaction_dispatches_events() {
627 let (command_tx, mut event_rx, join_handle) = start_thread();
628
629 exec(
630 &command_tx,
631 "CREATE TABLE sensors(id INTEGER PRIMARY KEY, name TEXT)",
632 )
633 .await
634 .expect("create table failed");
635
636 begin_tx(&command_tx).await;
637 exec(&command_tx, "INSERT INTO sensors(id, name) VALUES (1, 'a')")
638 .await
639 .expect("insert 1 failed");
640 exec(&command_tx, "INSERT INTO sensors(id, name) VALUES (2, 'b')")
641 .await
642 .expect("insert 2 failed");
643 commit_tx(&command_tx).await;
644
645 let first = timeout(Duration::from_secs(2), event_rx.recv())
646 .await
647 .expect("timeout waiting first event")
648 .expect("missing first event");
649 let second = timeout(Duration::from_secs(2), event_rx.recv())
650 .await
651 .expect("timeout waiting second event")
652 .expect("missing second event");
653
654 assert_eq!(first.action, Action::SQLITE_INSERT);
655 assert_eq!(second.action, Action::SQLITE_INSERT);
656
657 command_tx
658 .send(SqliteCommand::Shutdown)
659 .expect("failed to send shutdown");
660 join_handle.join().expect("thread join failed");
661 }
662
663 #[tokio::test]
664 async fn rolled_back_transaction_discards_events() {
665 let (command_tx, mut event_rx, join_handle) = start_thread();
666
667 exec(
668 &command_tx,
669 "CREATE TABLE sensors(id INTEGER PRIMARY KEY, name TEXT)",
670 )
671 .await
672 .expect("create table failed");
673
674 begin_tx(&command_tx).await;
675 exec(&command_tx, "INSERT INTO sensors(id, name) VALUES (1, 'a')")
676 .await
677 .expect("insert failed");
678 rollback_tx(&command_tx).await;
679
680 let receive = timeout(Duration::from_millis(200), event_rx.recv()).await;
681 assert!(receive.is_err(), "expected no event after rollback");
682
683 command_tx
684 .send(SqliteCommand::Shutdown)
685 .expect("failed to send shutdown");
686 join_handle.join().expect("thread join failed");
687 }
688
689 #[tokio::test]
690 async fn rollback_to_savepoint_discards_partial_events() {
691 let (command_tx, mut event_rx, join_handle) = start_thread();
692
693 exec(
694 &command_tx,
695 "CREATE TABLE sensors(id INTEGER PRIMARY KEY, name TEXT)",
696 )
697 .await
698 .expect("create table failed");
699
700 begin_tx(&command_tx).await;
701 exec(&command_tx, "INSERT INTO sensors(id, name) VALUES (1, 'a')")
702 .await
703 .expect("insert 1 failed");
704 exec(&command_tx, "SAVEPOINT s1")
705 .await
706 .expect("savepoint failed");
707 exec(&command_tx, "INSERT INTO sensors(id, name) VALUES (2, 'b')")
708 .await
709 .expect("insert 2 failed");
710 exec(&command_tx, "ROLLBACK TO SAVEPOINT s1")
711 .await
712 .expect("rollback savepoint failed");
713 exec(&command_tx, "INSERT INTO sensors(id, name) VALUES (3, 'c')")
714 .await
715 .expect("insert 3 failed");
716 commit_tx(&command_tx).await;
717
718 let first = timeout(Duration::from_secs(2), event_rx.recv())
719 .await
720 .expect("timeout waiting first event")
721 .expect("missing first event");
722 let second = timeout(Duration::from_secs(2), event_rx.recv())
723 .await
724 .expect("timeout waiting second event")
725 .expect("missing second event");
726 let third = timeout(Duration::from_millis(200), event_rx.recv()).await;
727
728 let first_id = first
729 .new_values
730 .as_ref()
731 .and_then(|values| values.iter().find(|(k, _)| k == "id").map(|(_, v)| v))
732 .cloned()
733 .unwrap_or_default();
734 let second_id = second
735 .new_values
736 .as_ref()
737 .and_then(|values| values.iter().find(|(k, _)| k == "id").map(|(_, v)| v))
738 .cloned()
739 .unwrap_or_default();
740
741 assert_eq!(first_id, serde_json::json!(1));
742 assert_eq!(second_id, serde_json::json!(3));
743 assert!(third.is_err(), "expected no third committed event");
744
745 command_tx
746 .send(SqliteCommand::Shutdown)
747 .expect("failed to send shutdown");
748 join_handle.join().expect("thread join failed");
749 }
750}