Skip to main content

faucet_sink_sqlite/
sink.rs

1//! SQLite sink implementation.
2
3use crate::config::{SqliteColumnMapping, SqliteSinkConfig};
4use async_trait::async_trait;
5use faucet_core::util::quote_ident;
6use faucet_core::{FaucetError, SchemaEvolution, SqlBaseType, json_schema_base_type};
7use serde_json::Value;
8use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
9use sqlx::{Row, SqlitePool};
10use std::str::FromStr;
11use std::time::Duration;
12
13/// Map a [`SqlBaseType`] to the SQLite column-type keyword used when adding a
14/// column during schema evolution (issue #194). SQLite uses dynamic typing
15/// (type affinity), so these are advisory affinities rather than strict types:
16/// `Boolean` maps to `INTEGER` (SQLite has no native boolean) and `Json` to
17/// `TEXT` (JSON is stored as text).
18fn sqlite_keyword(t: SqlBaseType) -> &'static str {
19    match t {
20        SqlBaseType::Integer => "INTEGER",
21        SqlBaseType::Double => "REAL",
22        SqlBaseType::Boolean => "INTEGER",
23        SqlBaseType::Text => "TEXT",
24        SqlBaseType::Json => "TEXT",
25    }
26}
27
28/// `ALTER TABLE <table> ADD COLUMN "<col>" <kw>` — SQLite has no
29/// `ADD COLUMN IF NOT EXISTS`, so [`SqliteSink::evolve_schema`] only emits this
30/// for columns it has already verified are absent (idempotency by pre-check).
31/// `table` is the unquoted table name; it is quoted here via [`quote_ident`].
32fn build_add_column_sql(table: &str, col: &str, t: SqlBaseType) -> String {
33    format!(
34        "ALTER TABLE {} ADD COLUMN {} {}",
35        quote_ident(table),
36        quote_ident(col),
37        sqlite_keyword(t)
38    )
39}
40
41/// Map a SQLite column affinity string (`PRAGMA table_info.type`, e.g. `INTEGER`,
42/// `REAL`, `VARCHAR(255)`, `TEXT`) to a JSON-Schema type fragment so
43/// [`SqliteSink::current_schema`] round-trips with [`faucet_core::diff_schema`].
44///
45/// SQLite determines affinity by a tolerant, case-insensitive substring match on
46/// the declared type (the rules in <https://www.sqlite.org/datatype3.html>), so
47/// this mirrors that: contains `INT` → integer; `CHAR`/`CLOB`/`TEXT` → string;
48/// `REAL`/`FLOA`/`DOUB` (and the loose `NUMERIC`/`DECIMAL`) → number; everything
49/// else falls back to string. `nullable` reflects `PRAGMA table_info.notnull == 0`.
50fn sqlite_affinity_to_json_schema(declared: &str, nullable: bool) -> serde_json::Value {
51    let up = declared.to_ascii_uppercase();
52    let contains = |needle: &str| up.contains(needle);
53    let base = if contains("INT") {
54        "integer"
55    } else if contains("CHAR") || contains("CLOB") || contains("TEXT") {
56        "string"
57    } else if contains("REAL")
58        || contains("FLOA")
59        || contains("DOUB")
60        || contains("NUMERIC")
61        || contains("DECIMAL")
62    {
63        "number"
64    } else {
65        "string"
66    };
67    if nullable {
68        serde_json::json!({ "type": [base, "null"] })
69    } else {
70        serde_json::json!({ "type": base })
71    }
72}
73
74/// Build the `ON CONFLICT(key) DO UPDATE …` tail for an upsert INSERT.
75/// Non-key columns are SET from `excluded`. If every column is a key column,
76/// emit `DO NOTHING`.
77fn on_conflict_clause(key: &[String], all_cols: &[String]) -> String {
78    let key_list = key
79        .iter()
80        .map(|k| quote_ident(k))
81        .collect::<Vec<_>>()
82        .join(", ");
83    let updates: Vec<String> = all_cols
84        .iter()
85        .filter(|c| !key.iter().any(|k| k == *c))
86        .map(|c| format!("{q} = excluded.{q}", q = quote_ident(c)))
87        .collect();
88    if updates.is_empty() {
89        format!("ON CONFLICT({key_list}) DO NOTHING")
90    } else {
91        format!(
92            "ON CONFLICT({key_list}) DO UPDATE SET {}",
93            updates.join(", ")
94        )
95    }
96}
97
98/// A sink that writes JSON records to a SQLite table.
99pub struct SqliteSink {
100    config: SqliteSinkConfig,
101    pool: SqlitePool,
102}
103
104impl SqliteSink {
105    /// Create a new SQLite sink. Establishes a connection pool.
106    ///
107    /// The pool opens each connection with `journal_mode = WAL` and a 5-second
108    /// `busy_timeout`. WAL lets a writer and readers proceed concurrently
109    /// instead of locking each other out, and the busy timeout makes a
110    /// connection wait-and-retry for the write lock rather than failing
111    /// immediately with `SQLITE_BUSY` under contention. `create_if_missing`
112    /// preserves the previous behaviour of creating the database file on first
113    /// open. WAL on a `sqlite::memory:` database is a harmless no-op.
114    pub async fn new(config: SqliteSinkConfig) -> Result<Self, FaucetError> {
115        config.write.validate()?;
116        if !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
117            && !matches!(config.column_mapping, SqliteColumnMapping::AutoMap)
118        {
119            return Err(FaucetError::Config(
120                "sqlite sink: write_mode upsert/delete requires column_mapping: auto_map \
121                 (key columns must be real columns, not inside a JSON blob)"
122                    .into(),
123            ));
124        }
125
126        let options = SqliteConnectOptions::from_str(&config.database_url)
127            .map_err(|e| FaucetError::Sink(format!("invalid SQLite database_url: {e}")))?
128            .create_if_missing(true)
129            .journal_mode(SqliteJournalMode::Wal)
130            .busy_timeout(Duration::from_secs(5));
131
132        let pool = SqlitePoolOptions::new()
133            .max_connections(config.max_connections)
134            .connect_with(options)
135            .await
136            .map_err(|e| FaucetError::Sink(format!("SQLite connection failed: {e}")))?;
137
138        Ok(Self { config, pool })
139    }
140
141    /// Insert JSON-column records within an existing transaction, sub-chunking
142    /// at SQLite's bind-variable cap. JSON mode binds one variable per row.
143    async fn insert_json_tx(
144        &self,
145        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
146        records: &[Value],
147        column: &str,
148    ) -> Result<usize, FaucetError> {
149        if records.is_empty() {
150            return Ok(0);
151        }
152        // SQLite caps bind params per statement at 32766 (>=3.32). JSON mode
153        // binds one variable per row, so chunk at that cap.
154        const MAX_SQLITE_VARS: usize = 32766;
155        for chunk in records.chunks(MAX_SQLITE_VARS) {
156            let placeholders: Vec<&str> = chunk.iter().map(|_| "(?)").collect();
157            let insert_sql = format!(
158                "INSERT INTO {} ({}) VALUES {}",
159                quote_ident(&self.config.table_name),
160                quote_ident(column),
161                placeholders.join(", ")
162            );
163            let mut q = sqlx::query(&insert_sql);
164            for record in chunk {
165                let json_str = serde_json::to_string(record)
166                    .map_err(|e| FaucetError::Sink(format!("failed to serialize record: {e}")))?;
167                q = q.bind(json_str);
168            }
169            q.execute(&mut **tx)
170                .await
171                .map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
172        }
173        Ok(records.len())
174    }
175
176    /// Insert a batch of records using JSON column mode.
177    /// Opens its own `BEGIN`/`COMMIT` transaction and delegates to
178    /// [`Self::insert_json_tx`], which sub-chunks at SQLite's bind-variable cap.
179    async fn insert_json(&self, records: &[Value], column: &str) -> Result<usize, FaucetError> {
180        if records.is_empty() {
181            return Ok(0);
182        }
183        let mut tx = self
184            .pool
185            .begin()
186            .await
187            .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
188        let n = self.insert_json_tx(&mut tx, records, column).await?;
189        tx.commit()
190            .await
191            .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
192        Ok(n)
193    }
194
195    /// Insert a batch of records using auto-mapped columns.
196    ///
197    /// Discovers column names from `pragma_table_info` and maps
198    /// top-level JSON fields to columns. Uses a single multi-row INSERT
199    /// wrapped in a transaction.
200    async fn insert_auto_map(&self, records: &[Value]) -> Result<usize, FaucetError> {
201        if records.is_empty() {
202            return Ok(0);
203        }
204
205        let mut tx = self
206            .pool
207            .begin()
208            .await
209            .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
210
211        let written = self.insert_auto_map_tx(&mut tx, records).await?;
212
213        tx.commit()
214            .await
215            .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
216
217        Ok(written)
218    }
219
220    /// Auto-map insert against an in-progress transaction.
221    ///
222    /// This is the reusable core shared by [`Self::insert_auto_map`] (which
223    /// opens its own `BEGIN`/`COMMIT`) and [`faucet_core::Sink::write_batch_idempotent`]
224    /// (which folds the insert and the commit-token upsert into one
225    /// transaction). The read-only `PRAGMA table_info` column-discovery query
226    /// runs on the transaction's own connection (`&mut **tx`), not on
227    /// `&self.pool` — otherwise, with the default single-connection pool, it
228    /// would deadlock waiting for a connection the open transaction is holding.
229    ///
230    /// When `conflict_key` is `Some(key)`, each sub-chunk's INSERT is given an
231    /// `ON CONFLICT(key) DO UPDATE …` tail so it upserts by the key columns
232    /// (last-write-wins within the batch is handled by the planner's dedup,
233    /// so a single sub-chunk never double-hits the same conflict target).
234    async fn insert_auto_map_with_conflict_tx(
235        &self,
236        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
237        records: &[Value],
238        conflict_key: Option<&[String]>,
239    ) -> Result<usize, FaucetError> {
240        if records.is_empty() {
241            return Ok(0);
242        }
243
244        // Get column names from the table using pragma_table_info. Use the
245        // transaction's connection so a single-connection pool doesn't deadlock.
246        let columns: Vec<String> = sqlx::query(&format!(
247            "PRAGMA table_info({})",
248            quote_ident(&self.config.table_name)
249        ))
250        .fetch_all(&mut **tx)
251        .await
252        .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
253        .iter()
254        .map(|row| row.get::<String, _>("name"))
255        .collect();
256
257        if columns.is_empty() {
258            return Err(FaucetError::Sink(format!(
259                "table '{}' has no columns or does not exist",
260                self.config.table_name
261            )));
262        }
263
264        // Pre-validate all records and collect matched column values. The
265        // INSERT column set is the UNION of table columns present in ANY record
266        // (in declared table order), not just the first record's keys —
267        // otherwise a field present only in a later record of the batch would be
268        // silently dropped (audit #146 H1). A row missing a unioned column binds
269        // SQL NULL.
270        let mut matched_rows: Vec<Vec<(&String, &Value)>> = Vec::with_capacity(records.len());
271        let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
272
273        for record in records {
274            let obj = record
275                .as_object()
276                .ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
277
278            let matching: Vec<(&String, &Value)> = columns
279                .iter()
280                .filter_map(|col| obj.get(col).map(|v| (col, v)))
281                .collect();
282
283            if matching.is_empty() {
284                tracing::warn!(
285                    record_keys = ?obj.keys().collect::<Vec<_>>(),
286                    table_columns = ?columns,
287                    "record has no keys matching table columns, skipping"
288                );
289                continue;
290            }
291
292            for (c, _) in &matching {
293                used.insert(c.as_str());
294            }
295            matched_rows.push(matching);
296        }
297
298        if matched_rows.is_empty() {
299            return Ok(0);
300        }
301
302        // Table columns (in declared order) that appear in at least one record.
303        let insert_columns: Vec<String> = columns
304            .iter()
305            .filter(|c| used.contains(c.as_str()))
306            .cloned()
307            .collect();
308
309        let num_cols = insert_columns.len();
310        let num_rows = matched_rows.len();
311        let col_names: Vec<String> = insert_columns.iter().map(|c| quote_ident(c)).collect();
312
313        // SQLite caps bind parameters per statement at SQLITE_MAX_VARIABLE_NUMBER
314        // (32766 since 3.32). A multi-row INSERT binds `rows × num_cols`
315        // parameters, so a wide table at a large batch_size can exceed it and
316        // fail at runtime with "too many SQL variables" (#78/#21). Split into
317        // sub-INSERTs of at most floor(MAX_VARS / num_cols) rows.
318        const MAX_SQLITE_VARS: usize = 32766;
319        let max_rows_per_insert = (MAX_SQLITE_VARS / num_cols).max(1);
320
321        for sub in matched_rows.chunks(max_rows_per_insert) {
322            // Build multi-row VALUES clause: (?, ?), (?, ?), ...
323            let row_placeholder = format!("({})", vec!["?"; num_cols].join(", "));
324            let value_tuples: Vec<&str> =
325                (0..sub.len()).map(|_| row_placeholder.as_str()).collect();
326            let base_query = format!(
327                "INSERT INTO {} ({}) VALUES {}",
328                quote_ident(&self.config.table_name),
329                col_names.join(", "),
330                value_tuples.join(", ")
331            );
332            let query = match conflict_key {
333                Some(key) => format!("{base_query} {}", on_conflict_clause(key, &insert_columns)),
334                None => base_query,
335            };
336
337            let mut q = sqlx::query(&query);
338            for matched in sub {
339                for col in &insert_columns {
340                    let val = matched.iter().find(|(c, _)| *c == col).map(|(_, v)| *v);
341                    // Bind native SQLite types so column affinity and typed reads
342                    // round-trip correctly. Binding every value as a JSON string
343                    // (the old behaviour) stored `"Bob"` with embedded quotes,
344                    // turned `true` into the text "true", and bound the literal
345                    // text "null" for absent columns instead of SQL NULL (#78/#4).
346                    q = match val {
347                        None | Some(Value::Null) => q.bind(None::<String>),
348                        Some(Value::Bool(b)) => q.bind(*b),
349                        Some(Value::Number(n)) => {
350                            if let Some(i) = n.as_i64() {
351                                q.bind(i)
352                            } else if let Some(f) = n.as_f64() {
353                                q.bind(f)
354                            } else {
355                                // u64 above i64::MAX — preserve exact text.
356                                q.bind(n.to_string())
357                            }
358                        }
359                        Some(Value::String(s)) => q.bind(s.clone()),
360                        // Arrays/objects have no scalar SQL representation — store
361                        // their JSON text (suitable for TEXT / JSON columns).
362                        Some(v) => q.bind(v.to_string()),
363                    };
364                }
365            }
366
367            q.execute(&mut **tx)
368                .await
369                .map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
370        }
371
372        Ok(num_rows)
373    }
374
375    /// Auto-map insert against an in-progress transaction with plain append
376    /// semantics (no `ON CONFLICT` tail).
377    ///
378    /// Thin wrapper over
379    /// [`insert_auto_map_with_conflict_tx`](Self::insert_auto_map_with_conflict_tx)
380    /// so the append path and the idempotent-write path keep their original
381    /// signature.
382    async fn insert_auto_map_tx(
383        &self,
384        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
385        records: &[Value],
386    ) -> Result<usize, FaucetError> {
387        self.insert_auto_map_with_conflict_tx(tx, records, None)
388            .await
389    }
390
391    /// Delete rows whose key columns match any of `deletes`, using
392    /// `DELETE FROM t WHERE (k1, …) IN ((?, …), …)`, chunked at
393    /// SQLite's bind-variable cap. Runs inside the caller's transaction.
394    async fn delete_by_keys(
395        &self,
396        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
397        deletes: &[faucet_core::KeyTuple],
398    ) -> Result<usize, FaucetError> {
399        if deletes.is_empty() {
400            return Ok(0);
401        }
402        let key = &self.config.write.key;
403        let table_ref = quote_ident(&self.config.table_name);
404        let col_list = key
405            .iter()
406            .map(|k| quote_ident(k))
407            .collect::<Vec<_>>()
408            .join(", ");
409
410        const MAX_SQLITE_VARS: usize = 32766;
411        let per = (MAX_SQLITE_VARS / key.len().max(1)).max(1);
412        let mut total = 0usize;
413
414        for chunk in deletes.chunks(per) {
415            let tuples: Vec<String> = chunk
416                .iter()
417                .map(|_| format!("({})", vec!["?"; key.len()].join(", ")))
418                .collect();
419            let sql = format!(
420                "DELETE FROM {table_ref} WHERE ({col_list}) IN ({})",
421                tuples.join(", ")
422            );
423            let mut q = sqlx::query(&sql);
424            for kt in chunk {
425                for (_, v) in &kt.0 {
426                    // Bind native SQLite types — same logic as in the INSERT path.
427                    q = match v {
428                        Value::Null => q.bind(None::<String>),
429                        Value::Bool(b) => q.bind(*b),
430                        Value::Number(n) => {
431                            if let Some(i) = n.as_i64() {
432                                q.bind(i)
433                            } else if let Some(f) = n.as_f64() {
434                                q.bind(f)
435                            } else {
436                                q.bind(n.to_string())
437                            }
438                        }
439                        Value::String(s) => q.bind(s.clone()),
440                        other => q.bind(other.to_string()),
441                    };
442                }
443            }
444            let res = q
445                .execute(&mut **tx)
446                .await
447                .map_err(|e| FaucetError::Sink(format!("SQLite delete failed: {e}")))?;
448            total += res.rows_affected() as usize;
449        }
450        Ok(total)
451    }
452
453    /// Apply a planned upsert/delete batch inside one `BEGIN`/`COMMIT`
454    /// transaction. Upserts and deletes are wrapped together so they commit
455    /// atomically.
456    async fn apply_plan(&self, plan: &faucet_core::WritePlan) -> Result<usize, FaucetError> {
457        let mut tx = self
458            .pool
459            .begin()
460            .await
461            .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
462
463        let mut affected = 0usize;
464        if !plan.upserts.is_empty() {
465            affected += self
466                .insert_auto_map_with_conflict_tx(
467                    &mut tx,
468                    &plan.upserts,
469                    Some(&self.config.write.key),
470                )
471                .await?;
472        }
473        if !plan.deletes.is_empty() {
474            affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
475        }
476
477        tx.commit()
478            .await
479            .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
480        Ok(affected)
481    }
482
483    /// Ensure the commit-token watermark table exists.
484    async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
485        let sql = format!(
486            "CREATE TABLE IF NOT EXISTS {t} ({s} TEXT PRIMARY KEY, {k} TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')))",
487            t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
488            s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
489            k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
490        );
491        sqlx::query(&sql)
492            .execute(&self.pool)
493            .await
494            .map_err(|e| FaucetError::Sink(format!("SQLite commit-table create failed: {e}")))?;
495        Ok(())
496    }
497}
498
499#[async_trait]
500impl faucet_core::Sink for SqliteSink {
501    fn config_schema(&self) -> serde_json::Value {
502        serde_json::to_value(faucet_core::schema_for!(SqliteSinkConfig))
503            .expect("schema serialization")
504    }
505
506    fn dataset_uri(&self) -> String {
507        let path = self
508            .config
509            .database_url
510            .trim_start_matches("sqlite://")
511            .trim_start_matches("sqlite:");
512        format!("sqlite://{}?table={}", path, self.config.table_name)
513    }
514
515    /// Preflight connectivity probe (`faucet doctor`).
516    ///
517    /// Acquires a connection from the existing pool and runs `SELECT 1`. This
518    /// is non-mutating and idempotent — it validates that the database file /
519    /// connection opens without writing anything.
520    async fn check(
521        &self,
522        ctx: &faucet_core::check::CheckContext,
523    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
524        use faucet_core::check::{CheckReport, Probe};
525
526        let started = std::time::Instant::now();
527        let probe =
528            match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
529                .await
530            {
531                Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
532                Ok(Err(e)) => Probe::fail_hint(
533                    "auth",
534                    started.elapsed(),
535                    e.to_string(),
536                    "check database_url / that the database file is reachable and openable",
537                ),
538                Err(_) => Probe::fail_hint(
539                    "auth",
540                    started.elapsed(),
541                    "timed out",
542                    "check database_url / that the database file is reachable and openable",
543                ),
544            };
545        Ok(CheckReport::single(probe))
546    }
547
548    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
549        &[
550            faucet_core::WriteMode::Append,
551            faucet_core::WriteMode::Upsert,
552            faucet_core::WriteMode::Delete,
553        ]
554    }
555
556    fn dedups_by_key(&self) -> bool {
557        self.config.write.dedups_by_key()
558    }
559
560    fn supports_schema_evolution(&self) -> bool {
561        true
562    }
563
564    /// Read the live destination schema via `PRAGMA table_info`, shaped as an
565    /// `infer_schema`-compatible object (`{"type":"object","properties":{…}}`),
566    /// or `None` when the target table does not exist yet (issue #194).
567    ///
568    /// `PRAGMA table_info` returns one row per column with `name`, `type` (the
569    /// declared affinity string), and `notnull`. The affinity string is mapped
570    /// to a JSON-Schema base type via `sqlite_affinity_to_json_schema`, and
571    /// `notnull == 0` surfaces the column as nullable. The PRAGMA runs on a
572    /// connection acquired from the pool (a standalone read — not inside an open
573    /// transaction).
574    async fn current_schema(&self) -> Result<Option<serde_json::Value>, FaucetError> {
575        let rows = sqlx::query(&format!(
576            "PRAGMA table_info({})",
577            quote_ident(&self.config.table_name)
578        ))
579        .fetch_all(&self.pool)
580        .await
581        .map_err(|e| FaucetError::Sink(format!("sqlite current_schema query failed: {e}")))?;
582
583        if rows.is_empty() {
584            return Ok(None); // table does not exist yet (or has no columns)
585        }
586
587        let mut props = serde_json::Map::new();
588        for row in &rows {
589            let name: String = row.get("name");
590            let declared: String = row.get("type");
591            let notnull: i64 = row.get("notnull");
592            props.insert(
593                name,
594                sqlite_affinity_to_json_schema(&declared, notnull == 0),
595            );
596        }
597        Ok(Some(
598            serde_json::json!({ "type": "object", "properties": props }),
599        ))
600    }
601
602    /// Apply an additive schema evolution to the destination table (issue #194).
603    ///
604    /// - **Additions** — `ALTER TABLE … ADD COLUMN`. SQLite has no
605    ///   `ADD COLUMN IF NOT EXISTS`, so the current columns are read first and a
606    ///   column already present is silently skipped (idempotency by pre-check).
607    /// - **Widenings** — a no-op under SQLite's dynamic typing: a column already
608    ///   accepts a value of any type, so there is nothing to ALTER. Logged once
609    ///   at `debug`.
610    /// - **Nullability relaxations** — a no-op: SQLite cannot drop a `NOT NULL`
611    ///   constraint in place (it requires a full table rebuild, which is out of
612    ///   scope here). Logged once at `debug`; the column is left as-is.
613    async fn evolve_schema(&self, evolution: &SchemaEvolution) -> Result<(), FaucetError> {
614        // Read the current column set so additions are idempotent (no
615        // `ADD COLUMN IF NOT EXISTS` in SQLite).
616        let existing: std::collections::HashSet<String> = sqlx::query(&format!(
617            "PRAGMA table_info({})",
618            quote_ident(&self.config.table_name)
619        ))
620        .fetch_all(&self.pool)
621        .await
622        .map_err(|e| FaucetError::Sink(format!("sqlite evolve table_info failed: {e}")))?
623        .iter()
624        .map(|row| row.get::<String, _>("name"))
625        .collect();
626
627        for c in &evolution.additions {
628            if existing.contains(&c.name) {
629                continue; // already present — ADD COLUMN would error
630            }
631            let t = json_schema_base_type(&c.to).unwrap_or(SqlBaseType::Text);
632            sqlx::query(&build_add_column_sql(&self.config.table_name, &c.name, t))
633                .execute(&self.pool)
634                .await
635                .map_err(|e| {
636                    FaucetError::Sink(format!("sqlite ADD COLUMN {} failed: {e}", c.name))
637                })?;
638        }
639
640        if !evolution.widenings.is_empty() {
641            tracing::debug!("sqlite: type widening is a no-op under dynamic typing");
642        }
643        for col in &evolution.relax_nullability {
644            tracing::debug!("sqlite cannot relax NOT NULL in place; column {col} left as-is");
645        }
646
647        Ok(())
648    }
649
650    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
651        if records.is_empty() {
652            return Ok(0);
653        }
654
655        // Non-append modes: plan the writes and apply atomically.
656        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
657            let plan = faucet_core::plan_writes(records, &self.config.write);
658            if let Some((idx, msg)) = plan.failed.first() {
659                return Err(FaucetError::Sink(format!(
660                    "sqlite {}: row {idx}: {msg}",
661                    self.config.write.write_mode.as_str()
662                )));
663            }
664            return self.apply_plan(&plan).await;
665        }
666
667        // `batch_size = 0` is the "no batching" sentinel: write the entire
668        // upstream slice as a single multi-row INSERT inside one
669        // `BEGIN`/`COMMIT` transaction, preserving `StreamPage` framing.
670        // Otherwise re-chunk into `batch_size` slices so each transaction
671        // stays near SQLite's sweet spot (~1000 rows per multi-row INSERT).
672        let effective_chunk = if self.config.batch_size == 0 {
673            records.len()
674        } else {
675            self.config.batch_size
676        };
677
678        let mut total = 0;
679        for chunk in records.chunks(effective_chunk) {
680            total += match &self.config.column_mapping {
681                SqliteColumnMapping::Json { column } => self.insert_json(chunk, column).await?,
682                SqliteColumnMapping::AutoMap => self.insert_auto_map(chunk).await?,
683            };
684        }
685
686        tracing::info!(
687            table = %self.config.table_name,
688            rows = total,
689            "SQLite write complete"
690        );
691        Ok(total)
692    }
693
694    /// Write a batch and report per-row outcomes.
695    ///
696    /// In append mode this delegates to [`write_batch`](faucet_core::Sink::write_batch) and
697    /// maps a single success onto an all-`Ok(())` vector (the trait default).
698    /// In upsert/delete mode the good rows are applied (upserts + deletes), and
699    /// only the rows whose key could not be extracted (missing / null key) are
700    /// reported as `Err` so the pipeline routes them to the DLQ per-row instead
701    /// of sending the whole page.
702    async fn write_batch_partial(
703        &self,
704        records: &[Value],
705    ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
706        if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
707            self.write_batch(records).await?;
708            return Ok(records.iter().map(|_| Ok(())).collect());
709        }
710
711        let plan = faucet_core::plan_writes(records, &self.config.write);
712        self.apply_plan(&plan).await?;
713
714        let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
715        for (idx, msg) in &plan.failed {
716            outcomes[*idx] = Err(FaucetError::Sink(format!(
717                "sqlite {}: {msg}",
718                self.config.write.write_mode.as_str()
719            )));
720        }
721        Ok(outcomes)
722    }
723
724    fn supports_idempotent_writes(&self) -> bool {
725        true
726    }
727
728    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
729        self.ensure_commit_table().await?;
730        let sql = format!(
731            "SELECT {k} FROM {t} WHERE {s} = ?",
732            t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
733            k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
734            s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
735        );
736        let row = sqlx::query(&sql)
737            .bind(scope)
738            .fetch_optional(&self.pool)
739            .await
740            .map_err(|e| FaucetError::Sink(format!("SQLite token read failed: {e}")))?;
741        Ok(row.map(|r| r.get::<String, _>(0)))
742    }
743
744    async fn write_batch_idempotent(
745        &self,
746        records: &[Value],
747        scope: &str,
748        token: &str,
749    ) -> Result<usize, FaucetError> {
750        self.ensure_commit_table().await?;
751
752        // For upsert/delete modes, plan the page before opening the transaction
753        // so a key-extraction failure aborts without leaving an open tx.
754        let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
755            None
756        } else {
757            let plan = faucet_core::plan_writes(records, &self.config.write);
758            if let Some((idx, msg)) = plan.failed.first() {
759                return Err(FaucetError::Sink(format!(
760                    "sqlite {}: row {idx}: {msg}",
761                    self.config.write.write_mode.as_str()
762                )));
763            }
764            Some(plan)
765        };
766
767        let mut tx = self
768            .pool
769            .begin()
770            .await
771            .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
772
773        // Data write and the commit-token upsert share ONE transaction so the
774        // page is committed atomically with its watermark: on crash either both
775        // land or neither does, which is what makes a replay skip-on-resume
776        // produce zero duplicates. For upsert/delete the planned upserts/deletes
777        // commit together with the watermark in this same tx (no nested tx —
778        // we reuse `apply_plan`'s helpers directly on this transaction).
779        let written = match &plan {
780            Some(plan) => {
781                let mut affected = 0usize;
782                if !plan.upserts.is_empty() {
783                    affected += self
784                        .insert_auto_map_with_conflict_tx(
785                            &mut tx,
786                            &plan.upserts,
787                            Some(&self.config.write.key),
788                        )
789                        .await?;
790                }
791                if !plan.deletes.is_empty() {
792                    affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
793                }
794                affected
795            }
796            None => match &self.config.column_mapping {
797                SqliteColumnMapping::Json { column } => {
798                    self.insert_json_tx(&mut tx, records, column).await?
799                }
800                SqliteColumnMapping::AutoMap => self.insert_auto_map_tx(&mut tx, records).await?,
801            },
802        };
803
804        let upsert = format!(
805            "INSERT INTO {t} ({s}, {k}) VALUES (?, ?) ON CONFLICT({s}) DO UPDATE SET {k} = excluded.{k}, updated_at = datetime('now')",
806            t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
807            s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
808            k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
809        );
810        sqlx::query(&upsert)
811            .bind(scope)
812            .bind(token)
813            .execute(&mut *tx)
814            .await
815            .map_err(|e| FaucetError::Sink(format!("SQLite token upsert failed: {e}")))?;
816
817        tx.commit()
818            .await
819            .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
820        Ok(written)
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use crate::config::SqliteSinkConfig;
828    use faucet_core::Sink as _;
829
830    #[tokio::test]
831    async fn dataset_uri_strips_sqlite_prefix_and_includes_table() {
832        let config = SqliteSinkConfig::new("sqlite:///tmp/test.db", "events");
833        let sink = SqliteSink::new(config).await.unwrap();
834        assert_eq!(sink.dataset_uri(), "sqlite:///tmp/test.db?table=events");
835    }
836
837    #[tokio::test]
838    async fn dataset_uri_with_memory_db() {
839        let config = SqliteSinkConfig::new("sqlite::memory:", "logs");
840        let sink = SqliteSink::new(config).await.unwrap();
841        assert_eq!(sink.dataset_uri(), "sqlite://:memory:?table=logs");
842    }
843
844    #[test]
845    fn sqlite_on_conflict_clause() {
846        let clause =
847            on_conflict_clause(&["id".to_string()], &["id".to_string(), "name".to_string()]);
848        assert_eq!(
849            clause,
850            r#"ON CONFLICT("id") DO UPDATE SET "name" = excluded."name""#
851        );
852    }
853
854    #[test]
855    fn sqlite_on_conflict_all_keys_does_nothing() {
856        let clause = on_conflict_clause(&["id".to_string()], &["id".to_string()]);
857        assert_eq!(clause, r#"ON CONFLICT("id") DO NOTHING"#);
858    }
859
860    #[test]
861    fn sqlite_on_conflict_composite_key() {
862        let clause = on_conflict_clause(
863            &["a".to_string(), "b".to_string()],
864            &["a".to_string(), "b".to_string(), "v".to_string()],
865        );
866        assert_eq!(
867            clause,
868            r#"ON CONFLICT("a", "b") DO UPDATE SET "v" = excluded."v""#
869        );
870    }
871
872    #[test]
873    fn sqlite_add_column_ddl() {
874        assert_eq!(
875            build_add_column_sql("t", "email", SqlBaseType::Text),
876            r#"ALTER TABLE "t" ADD COLUMN "email" TEXT"#
877        );
878        assert_eq!(
879            build_add_column_sql("t", "age", SqlBaseType::Integer),
880            r#"ALTER TABLE "t" ADD COLUMN "age" INTEGER"#
881        );
882        assert_eq!(
883            build_add_column_sql("t", "score", SqlBaseType::Double),
884            r#"ALTER TABLE "t" ADD COLUMN "score" REAL"#
885        );
886        // Boolean has no native SQLite type → INTEGER affinity; JSON → TEXT.
887        assert_eq!(
888            build_add_column_sql("t", "ok", SqlBaseType::Boolean),
889            r#"ALTER TABLE "t" ADD COLUMN "ok" INTEGER"#
890        );
891        assert_eq!(
892            build_add_column_sql("t", "meta", SqlBaseType::Json),
893            r#"ALTER TABLE "t" ADD COLUMN "meta" TEXT"#
894        );
895    }
896
897    #[test]
898    fn sqlite_keyword_mapping() {
899        assert_eq!(sqlite_keyword(SqlBaseType::Integer), "INTEGER");
900        assert_eq!(sqlite_keyword(SqlBaseType::Double), "REAL");
901        assert_eq!(sqlite_keyword(SqlBaseType::Boolean), "INTEGER");
902        assert_eq!(sqlite_keyword(SqlBaseType::Text), "TEXT");
903        assert_eq!(sqlite_keyword(SqlBaseType::Json), "TEXT");
904    }
905
906    #[test]
907    fn sqlite_affinity_round_trips_to_json_schema() {
908        use serde_json::json;
909        // Tolerant case-insensitive substring matching, SQLite affinity rules.
910        assert_eq!(
911            sqlite_affinity_to_json_schema("INTEGER", false),
912            json!({"type":"integer"})
913        );
914        assert_eq!(
915            sqlite_affinity_to_json_schema("BIGINT", false),
916            json!({"type":"integer"})
917        );
918        assert_eq!(
919            sqlite_affinity_to_json_schema("REAL", false),
920            json!({"type":"number"})
921        );
922        assert_eq!(
923            sqlite_affinity_to_json_schema("DOUBLE PRECISION", false),
924            json!({"type":"number"})
925        );
926        assert_eq!(
927            sqlite_affinity_to_json_schema("DECIMAL(10,2)", false),
928            json!({"type":"number"})
929        );
930        assert_eq!(
931            sqlite_affinity_to_json_schema("TEXT", false),
932            json!({"type":"string"})
933        );
934        assert_eq!(
935            sqlite_affinity_to_json_schema("VARCHAR(255)", false),
936            json!({"type":"string"})
937        );
938        // Unknown / empty affinity falls back to string.
939        assert_eq!(
940            sqlite_affinity_to_json_schema("BLOB", false),
941            json!({"type":"string"})
942        );
943        assert_eq!(
944            sqlite_affinity_to_json_schema("", false),
945            json!({"type":"string"})
946        );
947        // Nullable columns widen the type array.
948        assert_eq!(
949            sqlite_affinity_to_json_schema("integer", true),
950            json!({"type":["integer","null"]})
951        );
952    }
953}