Skip to main content

toasty_driver_turso/
lib.rs

1#![warn(missing_docs)]
2
3//! Toasty driver for [Turso](https://turso.tech/), an async-native,
4//! SQLite-compatible database engine.
5//!
6//! Speaks the same SQL dialect as the [SQLite driver][toasty-driver-sqlite]
7//! but uses the async Turso client. Supports file-backed and in-memory
8//! databases, an optional concurrent-writes mode that uses Turso's MVCC
9//! journal so transactions don't serialize on a single writer, and per-driver
10//! toggles for Turso's experimental features (`experimental_encryption`,
11//! `experimental_attach`, etc.) that mirror [`turso::Builder`].
12//!
13//! [toasty-driver-sqlite]: https://docs.rs/toasty-driver-sqlite
14//!
15//! # Examples
16//!
17//! ```
18//! use toasty_driver_turso::Turso;
19//!
20//! // In-memory database
21//! let driver = Turso::in_memory();
22//!
23//! // File-backed database
24//! let driver = Turso::file("path/to/db");
25//!
26//! // Allow transactions to run concurrently instead of serializing writers
27//! let driver = Turso::file("path/to/db").concurrent_writes();
28//! ```
29
30mod error;
31mod value;
32
33use error::classify_turso_error;
34
35/// Encryption configuration for Turso. Re-exported from the upstream
36/// `turso` crate so callers don't need a direct dependency on it.
37pub 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/// Driver builder options applied when opening a [`turso::Database`].
113#[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/// Opt-in flags for Turso's experimental features. Each field mirrors a
136/// `turso::Builder::experimental_*` method and is applied in
137/// [`LocalBuilderOptions::apply`] when the driver constructs a fresh
138/// [`turso::Builder`] at connection time.
139#[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            // Upstream requires *both* the feature flag and the
157            // key/cipher to be set; collapse them into a single call so
158            // callers can't get into a half-configured state.
159            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/// Sync configuration for a remote Turso database. Each field mirrors a
190/// `turso::sync::Builder` method and is applied in [`BuilderOptions::apply`]
191/// when the driver opens a [`turso::sync::Database`].
192#[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    /// Matches `turso::sync::Builder::new_remote`, which defaults this to
199    /// `true`.
200    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/// A Turso [`Driver`] that opens connections to a file or in-memory database.
289///
290/// Experimental Turso features are exposed as `experimental_*` builder
291/// methods that mirror [`turso::Builder`].
292///
293/// # Examples
294///
295/// ```rust,ignore
296/// use toasty_driver_turso::Turso;
297///
298/// // File-backed database
299/// let driver = Turso::file("path/to/db");
300///
301/// // With experimental features
302/// use toasty_driver_turso::EncryptionOpts;
303///
304/// let driver = Turso::file("path/to/db")
305///     .experimental_encryption(EncryptionOpts {
306///         cipher: "aes256gcm".into(),
307///         hexkey: "<64-hex-character-key>".into(),
308///     })
309///     .experimental_attach(true);
310///
311/// // Concurrent writes
312/// let driver = Turso::file("path/to/db").concurrent_writes();
313///
314/// // Syncing with remote server
315/// let driver = Turso::file("path/to/db")
316///     .with_remote_url("<remote-url>")
317///     .with_auth_token("<auth-token>");
318///
319/// driver.push().await?;
320/// ```
321#[derive(Clone)]
322pub struct Turso {
323    path: TursoPath,
324    options: BuilderOptions,
325    concurrent_writes: bool,
326    /// Shared `turso::Database` reused across every `connect()` call so that
327    /// all pool slots see the same underlying database. Without this, each
328    /// connection to `:memory:` would open a fresh empty database; even
329    /// file-backed handles open faster after the first builder run.
330    /// Cleared by [`Driver::reset_db`] so the next `connect()` starts fresh.
331    database: Arc<Mutex<Option<Database>>>,
332}
333
334impl Turso {
335    /// Create a new Turso driver from a connection URL.
336    ///
337    /// The URL scheme must be `turso` (e.g. `turso::memory:` or
338    /// `turso:/path/to/db`).
339    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    /// Create an in-memory Turso database.
363    pub fn in_memory() -> Self {
364        Self::with_path(TursoPath::InMemory)
365    }
366
367    /// Open a Turso database at the specified file path.
368    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    /// Allow transactions to run concurrently instead of serializing on a
382    /// single writer.
383    ///
384    /// When enabled, each new connection switches to Turso's MVCC journal
385    /// (`PRAGMA journal_mode = 'mvcc'`) and a transaction started with
386    /// [`TransactionMode::Default`](toasty_core::driver::operation::TransactionMode::Default)
387    /// — i.e. an unspecified mode — issues `BEGIN CONCURRENT`. Conflicting
388    /// transactions can then fail to commit and must be retried by the
389    /// caller.
390    ///
391    /// Callers can opt out of MVCC concurrency on a per-transaction basis by
392    /// requesting a different
393    /// [`TransactionMode`](toasty_core::driver::operation::TransactionMode):
394    /// `Deferred` falls back to plain `BEGIN`, while `Immediate` and
395    /// `Exclusive` issue `BEGIN IMMEDIATE` / `BEGIN EXCLUSIVE` respectively.
396    pub fn concurrent_writes(mut self) -> Self {
397        self.concurrent_writes = true;
398        self
399    }
400
401    /// Enable Turso's experimental index methods. With the `sync` feature,
402    /// mirrors `turso::sync::Builder::experimental_index_method`; otherwise
403    /// mirrors `turso::Builder::experimental_index_method`.
404    pub fn experimental_index_method(mut self, on: bool) -> Self {
405        self.options.index_method = on;
406        self
407    }
408
409    /// Enable Turso's experimental encryption with the given cipher and
410    /// key. Bundles `turso::Builder::experimental_encryption(true)` with
411    /// `turso::Builder::with_encryption(opts)` so callers cannot enable
412    /// encryption without supplying a key.
413    #[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    /// Enable Turso's experimental `ATTACH DATABASE` support. Mirrors
420    /// `turso::Builder::experimental_attach`.
421    #[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    /// Enable Turso's experimental custom types. Mirrors
428    /// `turso::Builder::experimental_custom_types`.
429    #[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    /// Enable Turso's experimental generated columns. Mirrors
436    /// `turso::Builder::experimental_generated_columns`.
437    #[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    /// Enable Turso's experimental materialized views. Mirrors
444    /// `turso::Builder::experimental_materialized_views`.
445    #[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    /// Enable Turso's experimental `VACUUM`. Mirrors
452    /// `turso::Builder::experimental_vacuum`.
453    #[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    /// Enable Turso's experimental multi-process WAL. Mirrors
460    /// `turso::Builder::experimental_multiprocess_wal`.
461    #[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    /// Enable Turso's experimental `WITHOUT ROWID` support. Mirrors
468    /// `turso::Builder::experimental_without_rowid`.
469    #[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    /// Set the remote base URL for sync HTTP requests. Mirrors
476    /// `turso::sync::Builder::with_remote_url`.
477    ///
478    /// Accepts `https://`, `http://` and `libsql://` URLs (`libsql://` is
479    /// translated to `https://`). If omitted on a file-backed database that
480    /// was previously synced, Turso loads the URL from on-disk metadata.
481    #[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    /// Set a static authorization token for sync HTTP requests. Mirrors
488    /// `turso::sync::Builder::with_auth_token`.
489    ///
490    /// The token is sent as a `Bearer` header (without the prefix in this
491    /// argument). Overridden by [`Self::with_auth_token_fn`] if called later.
492    #[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    /// Set an async callback that produces an auth token on demand. Mirrors
503    /// `turso::sync::Builder::with_auth_token_fn`.
504    ///
505    /// The callback runs before every HTTP request, so it can return a freshly
506    /// rotated token (for example from a secrets manager or OAuth refresh). If
507    /// the callback returns an error, the in-flight sync operation fails with
508    /// that error.
509    ///
510    /// Overrides any previously configured static token from
511    /// [`Self::with_auth_token`].
512    #[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    /// Set the client name reported to the sync engine. Mirrors
523    /// `turso::sync::Builder::with_client_name`.
524    ///
525    /// Defaults to `turso-sync-rust` when unset.
526    #[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    /// Set how long to wait on the remote when polling for changes. Mirrors
533    /// `turso::sync::Builder::with_long_poll_timeout`.
534    #[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    /// Set a base64-encoded encryption key and cipher for the remote Turso
541    /// Cloud database. Mirrors `turso::sync::Builder::with_remote_encryption`.
542    ///
543    /// The cipher determines `reserved_bytes` for page layout during bootstrap.
544    #[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    /// Set a base64-encoded encryption key for the remote Turso Cloud database.
557    /// Mirrors `turso::sync::Builder::with_remote_encryption_key`.
558    ///
559    /// The key is sent as the `x-turso-encryption-key` header on sync HTTP
560    /// requests. For deferred sync without an initial bootstrap, prefer
561    /// [`Self::with_remote_encryption`] so the cipher is set for correct
562    /// `reserved_bytes` calculation.
563    #[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    /// Enable or disable bootstrapping an empty local database from the remote.
570    /// Mirrors `turso::sync::Builder::bootstrap_if_empty`.
571    ///
572    /// When enabled and the local database is empty, the driver downloads
573    /// schema and initial data from the remote on first open. Upstream defaults
574    /// to enabled; call with `false` to skip bootstrap (for example when
575    /// attaching to an existing local file).
576    #[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    /// Set experimental partial-sync options. Mirrors
583    /// `turso::sync::Builder::with_partial_sync_opts_experimental`.
584    #[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    /// Push local changes to the remote. Mirrors
591    /// [`turso::sync::Database::push`].
592    ///
593    /// Operates on the shared [`turso::sync::Database`] cached by this driver,
594    /// so all connections in the pool see the same pending changes.
595    #[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    /// Pull remote changes and apply them locally. Mirrors
605    /// [`turso::sync::Database::pull`].
606    ///
607    /// Waits for remote changes, then applies them if any exist. Returns `true`
608    /// when changes were applied, `false` when the remote had nothing new.
609    #[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    /// Force a WAL checkpoint on the main database. Mirrors
619    /// [`turso::sync::Database::checkpoint`].
620    #[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    /// Return sync statistics for this database. Mirrors
630    /// [`turso::sync::Database::stats`].
631    #[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    /// Returns the cached `turso::Database`, opening it on first use.
648    ///
649    /// All connections handed out by [`Driver::connect`] go through the
650    /// same `Database` so that `:memory:` is genuinely shared across pool
651    /// slots (each `Builder::new_local(":memory:").build()` would otherwise
652    /// produce a fresh, empty database).
653    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            // `PRAGMA journal_mode = ...` returns the new mode as a row; the
703            // `execute` path errors with "unexpected row during execution"
704            // on any pragma that emits one. Use `pragma_update` so the row
705            // is consumed.
706            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        // Drop the cached Database so subsequent `connect()` calls open a
735        // fresh one. For in-memory this is the only way to wipe state;
736        // for file-backed databases the file is also removed below.
737        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
749/// An open connection to a Turso database.
750pub struct Connection {
751    conn: TursoConn,
752    /// SQL to issue for [`TransactionMode::Default`]. Resolved by the
753    /// driver at `connect()` time — either `"BEGIN"` for classic
754    /// deferred locking, or `"BEGIN CONCURRENT"` when the driver was
755    /// configured with `concurrent_writes()`. The connection no longer
756    /// needs to know which mode it was opened in; it just emits the
757    /// pre-baked command.
758    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                // `default_begin_sql` is the connection's "no opinion" BEGIN
885                // — `BEGIN` for classic mode, `BEGIN CONCURRENT` for MVCC —
886                // and the serializer maps the other `TransactionMode`s to
887                // standard SQLite SQL.
888                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    /// The file path `Turso::new` resolves out of a `turso:` URL.
1016    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        // `url::Url` stores the path percent-encoded: a space becomes `%20` and
1026        // non-ASCII bytes become `%XX` sequences. The driver must decode it back
1027        // before opening the file, otherwise it opens one whose name literally
1028        // contains `%20`.
1029        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        // Percent-decoding, not form-decoding: a literal `+` must stay a `+`.
1042        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}