1#![warn(missing_docs)]
2
3mod error;
31mod value;
32
33use error::classify_turso_error;
34
35pub use turso::EncryptionOpts;
38
39#[cfg(feature = "sync")]
40pub use turso::sync::{
41 DatabaseSyncStats, PartialBootstrapStrategy, PartialSyncOpts, RemoteEncryptionCipher,
42};
43
44use async_trait::async_trait;
45#[cfg(feature = "sync")]
46use std::future::Future;
47#[cfg(feature = "sync")]
48use std::time::Duration;
49use std::{
50 borrow::Cow,
51 fmt,
52 path::{Path, PathBuf},
53 sync::Arc,
54};
55use toasty_core::{
56 Result, Schema,
57 driver::{
58 Capability, ConnectContext, Driver, ExecResponse, QueryLogConfig,
59 log::QueryLog,
60 operation::{IsolationLevel, Operation, RawSqlRet, Transaction, TypedValue},
61 },
62 schema::{
63 db::{self, Migration, Table},
64 diff,
65 },
66 stmt,
67};
68use toasty_sql::{self as sql};
69use tokio::sync::Mutex;
70#[cfg(feature = "sync")]
71use turso::sync::{AuthTokenFn, Builder, Database};
72#[cfg(not(feature = "sync"))]
73use turso::{Builder, Database};
74use turso::{Connection as TursoConn, Statement, Value as TursoValue};
75use url::Url;
76
77enum SqlReturn {
78 Count,
79 Infer,
80 Types(Vec<stmt::Type>),
81}
82
83const CREATE_MIGRATIONS_TABLE: &str = "\
84CREATE TABLE IF NOT EXISTS __toasty_migrations (
85 id INTEGER PRIMARY KEY,
86 name TEXT NOT NULL,
87 applied_at TEXT NOT NULL
88 )";
89
90fn create_table_stmts(schema: &db::Schema, table: &Table) -> Vec<String> {
91 let serializer = sql::Serializer::sqlite(schema);
92
93 let mut stmts =
94 vec![serializer.serialize(&sql::Statement::create_table(table, &Capability::SQLITE))];
95
96 for index in &table.indices {
97 if index.primary_key {
98 continue;
99 }
100 stmts.push(serializer.serialize(&sql::Statement::create_index(index)));
101 }
102
103 stmts
104}
105
106#[derive(Debug, Clone)]
107enum TursoPath {
108 File(PathBuf),
109 InMemory,
110}
111
112#[derive(Debug, Default, Clone)]
114struct BuilderOptions {
115 index_method: bool,
116
117 #[cfg(not(feature = "sync"))]
118 local_options: LocalBuilderOptions,
119
120 #[cfg(feature = "sync")]
121 sync_options: SyncBuilderOptions,
122}
123
124#[cfg(not(feature = "sync"))]
125impl BuilderOptions {
126 fn apply(&self, mut b: Builder) -> Builder {
127 b = self.local_options.apply(b);
128 if self.index_method {
129 b = b.experimental_index_method(true);
130 }
131 b
132 }
133}
134
135#[cfg(not(feature = "sync"))]
140#[derive(Debug, Default, Clone)]
141struct LocalBuilderOptions {
142 encryption: Option<EncryptionOpts>,
143 attach: bool,
144 custom_types: bool,
145 generated_columns: bool,
146 materialized_views: bool,
147 vacuum: bool,
148 multiprocess_wal: bool,
149 without_rowid: bool,
150}
151
152#[cfg(not(feature = "sync"))]
153impl LocalBuilderOptions {
154 fn apply(&self, mut b: Builder) -> Builder {
155 if let Some(opts) = &self.encryption {
156 b = b
160 .experimental_encryption(true)
161 .with_encryption(opts.clone());
162 }
163 if self.attach {
164 b = b.experimental_attach(true);
165 }
166 if self.custom_types {
167 b = b.experimental_custom_types(true);
168 }
169 if self.generated_columns {
170 b = b.experimental_generated_columns(true);
171 }
172 if self.materialized_views {
173 b = b.experimental_materialized_views(true);
174 }
175 if self.vacuum {
176 b = b.experimental_vacuum(true);
177 }
178 if self.multiprocess_wal {
179 b = b.experimental_multiprocess_wal(true);
180 }
181 if self.without_rowid {
182 b = b.experimental_without_rowid(true);
183 }
184 b
185 }
186}
187
188#[cfg(feature = "sync")]
189#[derive(Clone)]
193struct SyncBuilderOptions {
194 remote_url: Option<String>,
195 auth_token: Option<AuthTokenFn>,
196 client_name: Option<String>,
197 long_poll_timeout: Option<Duration>,
198 bootstrap_if_empty: bool,
201 partial_sync_config_experimental: Option<PartialSyncOpts>,
202 remote_encryption: bool,
203 remote_encryption_key: Option<String>,
204 remote_encryption_cipher: Option<RemoteEncryptionCipher>,
205}
206
207#[cfg(feature = "sync")]
208impl Default for SyncBuilderOptions {
209 fn default() -> Self {
210 Self {
211 remote_url: None,
212 auth_token: None,
213 client_name: None,
214 long_poll_timeout: None,
215 bootstrap_if_empty: true,
216 partial_sync_config_experimental: None,
217 remote_encryption: false,
218 remote_encryption_key: None,
219 remote_encryption_cipher: None,
220 }
221 }
222}
223
224#[cfg(feature = "sync")]
225impl fmt::Debug for SyncBuilderOptions {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 f.debug_struct("SyncBuilderOptions")
228 .field("remote_url", &self.remote_url)
229 .field(
230 "auth_token",
231 &self.auth_token.as_ref().map(|_| "<callback>"),
232 )
233 .field("client_name", &self.client_name)
234 .field("long_poll_timeout", &self.long_poll_timeout)
235 .field("bootstrap_if_empty", &self.bootstrap_if_empty)
236 .field(
237 "partial_sync_config_experimental",
238 &self.partial_sync_config_experimental,
239 )
240 .field("remote_encryption", &self.remote_encryption)
241 .field(
242 "remote_encryption_key",
243 &self.remote_encryption_key.as_ref().map(|_| "<redacted>"),
244 )
245 .field("remote_encryption_cipher", &self.remote_encryption_cipher)
246 .finish()
247 }
248}
249
250#[cfg(feature = "sync")]
251impl BuilderOptions {
252 fn apply(&self, mut b: Builder) -> Builder {
253 if let Some(remote_url) = &self.sync_options.remote_url {
254 b = b.with_remote_url(remote_url)
255 }
256 if let Some(provider) = self.sync_options.auth_token.clone() {
257 b = b.with_auth_token_fn(move || provider());
258 }
259 if let Some(client_name) = &self.sync_options.client_name {
260 b = b.with_client_name(client_name)
261 }
262 if let Some(timeout) = self.sync_options.long_poll_timeout {
263 b = b.with_long_poll_timeout(timeout)
264 }
265 if let Some(opts) = &self.sync_options.partial_sync_config_experimental {
266 b = b.with_partial_sync_opts_experimental(opts.clone())
267 }
268 if self.sync_options.remote_encryption
269 && let (Some(base64_key), Some(cipher)) = (
270 &self.sync_options.remote_encryption_key,
271 &self.sync_options.remote_encryption_cipher,
272 )
273 {
274 b = b.with_remote_encryption(base64_key, *cipher)
275 } else if let Some(key) = &self.sync_options.remote_encryption_key {
276 b = b.with_remote_encryption_key(key);
277 }
278 if self.index_method {
279 b = b.experimental_index_method(true);
280 }
281 let bootstrap =
282 self.sync_options.remote_url.is_some() && self.sync_options.bootstrap_if_empty;
283 b = b.bootstrap_if_empty(bootstrap);
284 b
285 }
286}
287
288#[derive(Clone)]
322pub struct Turso {
323 path: TursoPath,
324 options: BuilderOptions,
325 concurrent_writes: bool,
326 database: Arc<Mutex<Option<Database>>>,
332}
333
334impl Turso {
335 pub fn new(url: impl Into<String>) -> Result<Self> {
340 let url_str = url.into();
341 let url = Url::parse(&url_str).map_err(toasty_core::Error::driver_operation_failed)?;
342
343 if url.scheme() != "turso" {
344 return Err(toasty_core::Error::invalid_connection_url(format!(
345 "connection URL does not have a `turso` scheme; url={url_str}"
346 )));
347 }
348
349 let path = if url.path() == ":memory:" {
350 TursoPath::InMemory
351 } else {
352 TursoPath::File(PathBuf::from(
353 percent_encoding::percent_decode(url.path().as_bytes())
354 .decode_utf8_lossy()
355 .to_string()
356 .as_str(),
357 ))
358 };
359 Ok(Self::with_path(path))
360 }
361
362 pub fn in_memory() -> Self {
364 Self::with_path(TursoPath::InMemory)
365 }
366
367 pub fn file<P: AsRef<Path>>(path: P) -> Self {
369 Self::with_path(TursoPath::File(path.as_ref().to_path_buf()))
370 }
371
372 fn with_path(path: TursoPath) -> Self {
373 Self {
374 path,
375 options: BuilderOptions::default(),
376 concurrent_writes: false,
377 database: Arc::new(Mutex::new(None)),
378 }
379 }
380
381 pub fn concurrent_writes(mut self) -> Self {
397 self.concurrent_writes = true;
398 self
399 }
400
401 pub fn experimental_index_method(mut self, on: bool) -> Self {
405 self.options.index_method = on;
406 self
407 }
408
409 #[cfg(not(feature = "sync"))]
414 pub fn experimental_encryption(mut self, opts: EncryptionOpts) -> Self {
415 self.options.local_options.encryption = Some(opts);
416 self
417 }
418
419 #[cfg(not(feature = "sync"))]
422 pub fn experimental_attach(mut self, on: bool) -> Self {
423 self.options.local_options.attach = on;
424 self
425 }
426
427 #[cfg(not(feature = "sync"))]
430 pub fn experimental_custom_types(mut self, on: bool) -> Self {
431 self.options.local_options.custom_types = on;
432 self
433 }
434
435 #[cfg(not(feature = "sync"))]
438 pub fn experimental_generated_columns(mut self, on: bool) -> Self {
439 self.options.local_options.generated_columns = on;
440 self
441 }
442
443 #[cfg(not(feature = "sync"))]
446 pub fn experimental_materialized_views(mut self, on: bool) -> Self {
447 self.options.local_options.materialized_views = on;
448 self
449 }
450
451 #[cfg(not(feature = "sync"))]
454 pub fn experimental_vacuum(mut self, on: bool) -> Self {
455 self.options.local_options.vacuum = on;
456 self
457 }
458
459 #[cfg(not(feature = "sync"))]
462 pub fn experimental_multiprocess_wal(mut self, on: bool) -> Self {
463 self.options.local_options.multiprocess_wal = on;
464 self
465 }
466
467 #[cfg(not(feature = "sync"))]
470 pub fn experimental_without_rowid(mut self, on: bool) -> Self {
471 self.options.local_options.without_rowid = on;
472 self
473 }
474
475 #[cfg(feature = "sync")]
482 pub fn with_remote_url(mut self, remote_url: impl Into<String>) -> Self {
483 self.options.sync_options.remote_url = Some(remote_url.into());
484 self
485 }
486
487 #[cfg(feature = "sync")]
493 pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
494 let token = token.into();
495 self.options.sync_options.auth_token = Some(Arc::new(move || {
496 let token = token.clone();
497 Box::pin(async move { Ok(token) })
498 }));
499 self
500 }
501
502 #[cfg(feature = "sync")]
513 pub fn with_auth_token_fn<F, Fut>(mut self, f: F) -> Self
514 where
515 F: Fn() -> Fut + Send + Sync + 'static,
516 Fut: Future<Output = turso::Result<String>> + Send + 'static,
517 {
518 self.options.sync_options.auth_token = Some(Arc::new(move || Box::pin(f())));
519 self
520 }
521
522 #[cfg(feature = "sync")]
527 pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
528 self.options.sync_options.client_name = Some(name.into());
529 self
530 }
531
532 #[cfg(feature = "sync")]
535 pub fn with_long_poll_timeout(mut self, timeout: Duration) -> Self {
536 self.options.sync_options.long_poll_timeout = Some(timeout);
537 self
538 }
539
540 #[cfg(feature = "sync")]
545 pub fn with_remote_encryption(
546 mut self,
547 base64_key: impl Into<String>,
548 cipher: RemoteEncryptionCipher,
549 ) -> Self {
550 self.options.sync_options.remote_encryption = true;
551 self.options.sync_options.remote_encryption_key = Some(base64_key.into());
552 self.options.sync_options.remote_encryption_cipher = Some(cipher);
553 self
554 }
555
556 #[cfg(feature = "sync")]
564 pub fn with_remote_encryption_key(mut self, base64_key: impl Into<String>) -> Self {
565 self.options.sync_options.remote_encryption_key = Some(base64_key.into());
566 self
567 }
568
569 #[cfg(feature = "sync")]
577 pub fn bootstrap_if_empty(mut self, enable: bool) -> Self {
578 self.options.sync_options.bootstrap_if_empty = enable;
579 self
580 }
581
582 #[cfg(feature = "sync")]
585 pub fn experimental_with_partial_sync_opts(mut self, opts: PartialSyncOpts) -> Self {
586 self.options.sync_options.partial_sync_config_experimental = Some(opts);
587 self
588 }
589
590 #[cfg(feature = "sync")]
596 pub async fn push(&self) -> Result<()> {
597 self.database()
598 .await?
599 .push()
600 .await
601 .map_err(classify_turso_error)
602 }
603
604 #[cfg(feature = "sync")]
610 pub async fn pull(&self) -> Result<bool> {
611 self.database()
612 .await?
613 .pull()
614 .await
615 .map_err(classify_turso_error)
616 }
617
618 #[cfg(feature = "sync")]
621 pub async fn checkpoint(&self) -> Result<()> {
622 self.database()
623 .await?
624 .checkpoint()
625 .await
626 .map_err(classify_turso_error)
627 }
628
629 #[cfg(feature = "sync")]
632 pub async fn stats(&self) -> Result<DatabaseSyncStats> {
633 self.database()
634 .await?
635 .stats()
636 .await
637 .map_err(classify_turso_error)
638 }
639
640 fn path_str(&self) -> &str {
641 match &self.path {
642 TursoPath::File(p) => p.to_str().unwrap_or(":memory:"),
643 TursoPath::InMemory => ":memory:",
644 }
645 }
646
647 async fn database(&self) -> Result<Database> {
654 let mut slot = self.database.lock().await;
655 if let Some(db) = slot.as_ref() {
656 return Ok(db.clone());
657 }
658
659 #[cfg(not(feature = "sync"))]
660 let builder = self.options.apply(Builder::new_local(self.path_str()));
661 #[cfg(feature = "sync")]
662 let builder = self.options.apply(Builder::new_remote(self.path_str()));
663
664 let db = builder.build().await.map_err(classify_turso_error)?;
665 *slot = Some(db.clone());
666 Ok(db)
667 }
668}
669
670impl fmt::Debug for Turso {
671 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
672 f.debug_struct("Turso")
673 .field("path", &self.path)
674 .field("concurrent_writes", &self.concurrent_writes)
675 .field("options", &self.options)
676 .finish_non_exhaustive()
677 }
678}
679
680#[async_trait]
681impl Driver for Turso {
682 fn url(&self) -> Cow<'_, str> {
683 match &self.path {
684 TursoPath::InMemory => Cow::Borrowed("turso::memory:"),
685 TursoPath::File(path) => Cow::Owned(format!("turso:{}", path.display())),
686 }
687 }
688
689 fn capability(&self) -> &'static Capability {
690 &Capability::TURSO
691 }
692
693 async fn connect(&self, cx: &ConnectContext) -> Result<Box<dyn toasty_core::Connection>> {
694 let db = self.database().await?;
695
696 #[cfg(not(feature = "sync"))]
697 let conn = db.connect().map_err(classify_turso_error)?;
698 #[cfg(feature = "sync")]
699 let conn = db.connect().await.map_err(classify_turso_error)?;
700
701 if self.concurrent_writes {
702 conn.pragma_update("journal_mode", "'mvcc'")
707 .await
708 .map_err(classify_turso_error)?;
709 }
710
711 Ok(Box::new(Connection {
712 conn,
713 default_begin_sql: if self.concurrent_writes {
714 "BEGIN CONCURRENT"
715 } else {
716 "BEGIN"
717 },
718 query_log: cx.query_log,
719 }))
720 }
721
722 fn generate_migration(&self, schema_diff: &diff::Schema<'_>) -> Migration {
723 let statements = sql::MigrationStatement::from_diff(schema_diff, &Capability::SQLITE);
724
725 let sql_strings: Vec<String> = statements
726 .iter()
727 .map(|stmt| sql::Serializer::sqlite(stmt.schema()).serialize(stmt.statement()))
728 .collect();
729
730 Migration::new_sql_with_breakpoints(&sql_strings)
731 }
732
733 async fn reset_db(&self) -> Result<()> {
734 self.database.lock().await.take();
738
739 if let TursoPath::File(path) = &self.path
740 && path.exists()
741 {
742 std::fs::remove_file(path).map_err(toasty_core::Error::driver_operation_failed)?;
743 }
744
745 Ok(())
746 }
747}
748
749pub struct Connection {
751 conn: TursoConn,
752 default_begin_sql: &'static str,
759 query_log: QueryLogConfig,
760}
761
762impl fmt::Debug for Connection {
763 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
764 f.debug_struct("Connection").finish()
765 }
766}
767
768impl Connection {
769 async fn exec_sql(
770 &mut self,
771 sql_str: &str,
772 typed_params: Vec<TypedValue>,
773 ret: SqlReturn,
774 ) -> Result<ExecResponse> {
775 let mut log = QueryLog::sql(
776 &self.query_log,
777 "turso",
778 sql_str,
779 typed_params.iter().map(|tv| &tv.value),
780 );
781 let result = self
782 .exec_sql_inner(sql_str, typed_params, ret, &mut log)
783 .await;
784 log.finish(&result);
785 result
786 }
787
788 async fn exec_sql_inner(
789 &mut self,
790 sql_str: &str,
791 typed_params: Vec<TypedValue>,
792 ret: SqlReturn,
793 log: &mut QueryLog<'_>,
794 ) -> Result<ExecResponse> {
795 let params: Vec<TursoValue> = typed_params
796 .iter()
797 .map(|tv| value::to_turso(&tv.value))
798 .collect();
799
800 let mut stmt: Statement = self
801 .conn
802 .prepare_cached(sql_str)
803 .await
804 .map_err(classify_turso_error)?;
805
806 if matches!(ret, SqlReturn::Count) {
807 let count = stmt.execute(params).await.map_err(classify_turso_error)?;
808
809 return Ok(ExecResponse::count(count as _));
810 }
811
812 let mut rows = stmt.query(params).await.map_err(classify_turso_error)?;
813
814 let mut values = vec![];
815
816 loop {
817 match rows.next().await {
818 Ok(Some(row)) => {
819 let items = match &ret {
820 SqlReturn::Count => unreachable!(),
821 SqlReturn::Infer => {
822 let mut items = vec![];
823 for index in 0..row.column_count() {
824 let turso_val =
825 row.get_value(index).map_err(classify_turso_error)?;
826 items.push(value::from_turso_infer(turso_val));
827 }
828 items
829 }
830 SqlReturn::Types(ret_tys) => {
831 let mut items = Vec::with_capacity(ret_tys.len());
832 for (index, ret_ty) in ret_tys.iter().enumerate() {
833 let turso_val =
834 row.get_value(index).map_err(classify_turso_error)?;
835 items.push(value::from_turso(turso_val, ret_ty));
836 }
837 items
838 }
839 };
840
841 values.push(stmt::ValueRecord::from_vec(items).into());
842 }
843 Ok(None) => break,
844 Err(err) => return Err(classify_turso_error(err)),
845 }
846 }
847
848 log.rows(values.len() as u64);
849 Ok(ExecResponse::value_stream(stmt::ValueStream::from_vec(
850 values,
851 )))
852 }
853}
854
855#[async_trait]
856impl toasty_core::driver::Connection for Connection {
857 async fn exec(&mut self, schema: &Arc<Schema>, op: Operation) -> Result<ExecResponse> {
858 tracing::trace!(driver = "turso", op = %op.name(), "driver exec");
859
860 let (sql, typed_params, ret_tys) = match op {
861 Operation::QuerySql(op) => {
862 assert!(
863 op.last_insert_id_hack.is_none(),
864 "last_insert_id_hack is MySQL-specific and should not be set for Turso"
865 );
866 (sql::Statement::from(op.stmt), op.params, op.ret)
867 }
868 Operation::RawSql(op) => {
869 let ret = match op.ret {
870 RawSqlRet::None => SqlReturn::Count,
871 RawSqlRet::Infer => SqlReturn::Infer,
872 RawSqlRet::Types(types) => SqlReturn::Types(types),
873 };
874 return self.exec_sql(&op.sql, op.params, ret).await;
875 }
876 Operation::Transaction(op) => {
877 if let Transaction::Start { isolation, .. } = &op
878 && !matches!(isolation, Some(IsolationLevel::Serializable) | None)
879 {
880 return Err(toasty_core::Error::unsupported_feature(
881 "Turso only supports Serializable isolation",
882 ));
883 }
884 let sql_str =
889 sql::Serializer::sqlite_with_default_begin(&schema.db, self.default_begin_sql)
890 .serialize_transaction(&op);
891 self.conn
892 .execute(&sql_str, ())
893 .await
894 .map_err(classify_turso_error)?;
895 return Ok(ExecResponse::count(0));
896 }
897 _ => todo!("op={:#?}", op),
898 };
899
900 let ret = if sql.returning_len().is_some() {
901 SqlReturn::Types(ret_tys.unwrap())
902 } else {
903 SqlReturn::Count
904 };
905
906 let sql_str = sql::Serializer::sqlite(&schema.db).serialize(&sql);
907 self.exec_sql(&sql_str, typed_params, ret).await
908 }
909
910 async fn push_schema(&mut self, schema: &Schema) -> Result<()> {
911 for table in &schema.db.tables {
912 tracing::debug!(table = %table.name, "creating table");
913 for sql in create_table_stmts(&schema.db, table) {
914 self.conn
915 .execute(&sql, ())
916 .await
917 .map_err(classify_turso_error)?;
918 }
919 }
920
921 Ok(())
922 }
923
924 async fn applied_migrations(
925 &mut self,
926 ) -> Result<Vec<toasty_core::schema::db::AppliedMigration>> {
927 self.conn
928 .execute(CREATE_MIGRATIONS_TABLE, ())
929 .await
930 .map_err(classify_turso_error)?;
931
932 let mut rows = self
933 .conn
934 .query("SELECT id FROM __toasty_migrations ORDER BY applied_at", ())
935 .await
936 .map_err(classify_turso_error)?;
937
938 let mut migrations = vec![];
939 loop {
940 match rows.next().await {
941 Ok(Some(row)) => {
942 let val = row.get_value(0).map_err(classify_turso_error)?;
943 if let TursoValue::Integer(id) = val {
944 migrations.push(toasty_core::schema::db::AppliedMigration::new(id as u64));
945 }
946 }
947 Ok(None) => break,
948 Err(err) => return Err(classify_turso_error(err)),
949 }
950 }
951
952 Ok(migrations)
953 }
954
955 async fn apply_migration(
956 &mut self,
957 id: u64,
958 name: &str,
959 migration: &toasty_core::schema::db::Migration,
960 ) -> Result<()> {
961 tracing::info!(id = id, name = %name, "applying migration");
962
963 self.conn
964 .execute(CREATE_MIGRATIONS_TABLE, ())
965 .await
966 .map_err(classify_turso_error)?;
967
968 self.conn
969 .execute("BEGIN", ())
970 .await
971 .map_err(classify_turso_error)?;
972
973 for statement in migration.statements() {
974 if let Err(e) = self
975 .conn
976 .execute(statement, ())
977 .await
978 .map_err(classify_turso_error)
979 {
980 let _ = self.conn.execute("ROLLBACK", ()).await;
981 return Err(e);
982 }
983 }
984
985 if let Err(e) = self
986 .conn
987 .execute(
988 "INSERT INTO __toasty_migrations (id, name, applied_at) VALUES (?1, ?2, datetime('now'))",
989 vec![
990 TursoValue::Integer(id as i64),
991 TursoValue::Text(name.to_string()),
992 ],
993 )
994 .await
995 .map_err(classify_turso_error)
996 {
997 let _ = self.conn.execute("ROLLBACK", ()).await;
998 return Err(e);
999 }
1000
1001 self.conn
1002 .execute("COMMIT", ())
1003 .await
1004 .map_err(classify_turso_error)?;
1005
1006 Ok(())
1007 }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::{Turso, TursoPath};
1013 use std::path::PathBuf;
1014
1015 fn file_path(url: &str) -> PathBuf {
1017 match Turso::new(url).unwrap().path {
1018 TursoPath::File(path) => path,
1019 TursoPath::InMemory => panic!("expected a file-backed database for {url}"),
1020 }
1021 }
1022
1023 #[test]
1024 fn new_decodes_percent_encoded_path() {
1025 assert_eq!(
1030 file_path("turso:/tmp/my db.sqlite"),
1031 PathBuf::from("/tmp/my db.sqlite")
1032 );
1033 assert_eq!(
1034 file_path("turso:///tmp/my%20db.sqlite"),
1035 PathBuf::from("/tmp/my db.sqlite")
1036 );
1037 assert_eq!(
1038 file_path("turso:/tmp/d%C3%A9j%C3%A0.db"),
1039 PathBuf::from("/tmp/déjà.db")
1040 );
1041 assert_eq!(file_path("turso:/tmp/a+b.db"), PathBuf::from("/tmp/a+b.db"));
1043 }
1044
1045 #[test]
1046 fn new_memory_url_stays_in_memory() {
1047 assert!(matches!(
1048 Turso::new("turso::memory:").unwrap().path,
1049 TursoPath::InMemory
1050 ));
1051 }
1052}
1053
1054#[cfg(all(test, feature = "sync"))]
1055mod sync_tests {
1056 use super::{Turso, TursoValue};
1057 use reqwest::Client;
1058 use serde_json::{Value, json};
1059 use std::time::Duration;
1060 use tokio::time::{Instant, sleep};
1061
1062 struct TursoTestServer {
1063 client: Client,
1064 db_url: String,
1065 }
1066
1067 impl TursoTestServer {
1068 pub async fn new() -> Self {
1069 let client = Client::new();
1070 let db_url = std::env::var("TOASTY_TEST_TURSO_SYNC_URL")
1071 .unwrap_or("http://127.0.0.1:8080".to_string());
1072
1073 let deadline = Instant::now() + Duration::from_secs(5);
1074 loop {
1075 if client.get(&db_url).send().await.is_ok() {
1076 break;
1077 }
1078 if Instant::now() >= deadline {
1079 panic!("Turso sync server did not become ready within 30s; url={db_url}");
1080 }
1081 sleep(Duration::from_millis(100)).await;
1082 }
1083
1084 Self { client, db_url }
1085 }
1086
1087 async fn run_sql(&self, sql: &str) -> Vec<Value> {
1088 let resp: Value = self
1089 .client
1090 .post(format!("{}/v2/pipeline", self.db_url))
1091 .json(&json!({
1092 "requests": [{
1093 "type": "execute",
1094 "stmt": { "sql": sql }
1095 }]
1096 }))
1097 .send()
1098 .await
1099 .unwrap()
1100 .error_for_status()
1101 .unwrap()
1102 .json()
1103 .await
1104 .unwrap();
1105
1106 let result = &resp["results"][0];
1107 if result["type"] != "ok" {
1108 panic!("pipeline failed: {resp}");
1109 }
1110 result["response"]["result"]["rows"]
1111 .as_array()
1112 .unwrap()
1113 .clone()
1114 }
1115 }
1116
1117 #[tokio::test]
1118 async fn test_sync_push_and_pull() {
1119 let server = TursoTestServer::new().await;
1120 server.run_sql("DROP TABLE IF EXISTS t").await;
1121
1122 let driver = Turso::in_memory().with_remote_url(&server.db_url);
1123 let conn = driver.database().await.unwrap().connect().await.unwrap();
1124
1125 conn.execute("DROP TABLE IF EXISTS t", ()).await.unwrap();
1126 conn.execute("CREATE TABLE t (x TEXT)", ()).await.unwrap();
1127 conn.execute("INSERT INTO t VALUES ('test'), ('test-2')", ())
1128 .await
1129 .unwrap();
1130
1131 driver.push().await.unwrap();
1132
1133 let rows = server.run_sql("SELECT x FROM t ORDER BY x").await;
1134 assert_eq!(
1135 rows,
1136 vec![
1137 json!([{"type": "text", "value": "test"}]),
1138 json!([{"type": "text", "value": "test-2"}]),
1139 ]
1140 );
1141
1142 server.run_sql("INSERT INTO t VALUES ('test-3')").await;
1143
1144 driver.pull().await.unwrap();
1145
1146 let mut local_rows = conn.query("SELECT x FROM t ORDER BY x", ()).await.unwrap();
1147
1148 let mut values = vec![];
1149 while let Some(row) = local_rows.next().await.unwrap() {
1150 if let TursoValue::Text(s) = row.get_value(0).unwrap() {
1151 values.push(s);
1152 }
1153 }
1154 assert_eq!(values, vec!["test", "test-2", "test-3"]);
1155 }
1156
1157 #[tokio::test]
1158 async fn test_local_db_without_remote_url() {
1159 let server = TursoTestServer::new().await;
1160 server.run_sql("DROP TABLE IF EXISTS t").await;
1161
1162 let driver = Turso::in_memory();
1163 let _ = driver.database().await.unwrap().connect().await.unwrap();
1164 }
1165}