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/// Quote a SQLite identifier with backticks.
14///
15/// Deliberately NOT ANSI double quotes for the scoped-cleanup path: SQLite's
16/// double-quoted-string misfeature silently reinterprets a double-quoted
17/// identifier that does not resolve to a column as a **string literal**. In a
18/// cleanup that is unacceptable — a typo'd scope column would make
19/// `t."typo" = ?` a constant comparison instead of erroring, and the DELETE
20/// would then match the wrong rows (or every row in the table). Backtick-quoted
21/// identifiers are always identifiers, so an unknown column surfaces as a
22/// proper "no such column" error. Embedded backticks are doubled, preventing
23/// identifier injection. Mirrors `quote_ident_sqlite` in `faucet-source-sqlite`.
24fn quote_ident_sqlite(name: &str) -> String {
25    format!("`{}`", name.replace('`', "``"))
26}
27
28/// Transient table holding the key tuples this run wrote, joined against by the
29/// scoped-cleanup DELETE (#478).
30///
31/// Always created in — and referenced through — the `temp` schema, so it can
32/// never be confused with (or, on the defensive `DROP`, destroy) a real table of
33/// the same name in the main database.
34const CLEANUP_KEYS_TABLE: &str = "faucet_cleanup_keys";
35
36/// Schema-qualified, quoted reference to [`CLEANUP_KEYS_TABLE`].
37fn cleanup_keys_ref() -> String {
38    format!("temp.{}", quote_ident_sqlite(CLEANUP_KEYS_TABLE))
39}
40
41/// A declared SQLite column type (`PRAGMA table_info.type`) that is safe to
42/// re-emit verbatim in the cleanup temp table's DDL, or `None`.
43///
44/// The declared type comes from the database's own catalog, but SQLite lets a
45/// column be declared with *arbitrary quoted text*, so it is filtered to the
46/// shape real type specs take (`VARCHAR(255)`, `DOUBLE PRECISION`,
47/// `DECIMAL(10, 2)`) rather than pasted in blind. `None` means "declare the temp
48/// column without a type" — legal in SQLite, and only costs the column its type
49/// affinity.
50fn safe_type_spec(declared: &str) -> Option<&str> {
51    let t = declared.trim();
52    if t.is_empty() {
53        return None;
54    }
55    t.chars()
56        .all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '(' | ')' | ',' | '.'))
57        .then_some(t)
58}
59
60/// `CREATE TEMP TABLE temp.`faucet_cleanup_keys` (…)` — one column per key
61/// column, mirroring the destination column's declared type so the join
62/// comparison sees matching type affinities.
63fn build_cleanup_temp_table_sql(key_types: &[(String, String)]) -> String {
64    let cols = key_types
65        .iter()
66        .map(|(col, declared)| match safe_type_spec(declared) {
67            Some(t) => format!("{} {t}", quote_ident_sqlite(col)),
68            None => quote_ident_sqlite(col),
69        })
70        .collect::<Vec<_>>()
71        .join(", ");
72    format!("CREATE TEMP TABLE {} ({cols})", cleanup_keys_ref())
73}
74
75/// `INSERT INTO temp.`faucet_cleanup_keys` (…) VALUES (?, …), …` for `rows`
76/// key tuples. The caller chunks `rows` to stay under SQLite's bind-variable
77/// cap.
78fn build_cleanup_insert_sql(key: &[String], rows: usize) -> String {
79    let col_list = key
80        .iter()
81        .map(|k| quote_ident_sqlite(k))
82        .collect::<Vec<_>>()
83        .join(", ");
84    let tuple = format!("({})", vec!["?"; key.len()].join(", "));
85    let tuples = vec![tuple; rows].join(", ");
86    format!(
87        "INSERT INTO {} ({col_list}) VALUES {tuples}",
88        cleanup_keys_ref()
89    )
90}
91
92/// The cleanup DELETE: every row matching the scope (equality predicates,
93/// AND-ed, one bind each) whose key is absent from the written-key table.
94///
95/// The target table is referenced by name rather than an alias — a single-table
96/// `DELETE … AS alias` is a newer SQLite grammar, and the correlated reference
97/// works identically through the table name.
98fn build_cleanup_delete_sql(table: &str, scope_cols: &[String], key: &[String]) -> String {
99    let t = quote_ident_sqlite(table);
100    let scope_pred = scope_cols
101        .iter()
102        .map(|c| format!("{t}.{} = ?", quote_ident_sqlite(c)))
103        .collect::<Vec<_>>()
104        .join(" AND ");
105    let join_pred = key
106        .iter()
107        .map(|k| {
108            let q = quote_ident_sqlite(k);
109            format!("c.{q} = {t}.{q}")
110        })
111        .collect::<Vec<_>>()
112        .join(" AND ");
113    format!(
114        "DELETE FROM {t} WHERE {scope_pred} AND NOT EXISTS (SELECT 1 FROM {} c WHERE {join_pred})",
115        cleanup_keys_ref()
116    )
117}
118
119/// Check that every scope and key column exists on the destination table.
120///
121/// Fails with a clear message rather than letting SQLite reject an unknown
122/// column mid-DELETE. The scope is written in *destination* terms, so a name
123/// that isn't a real column is a config error worth naming.
124fn validate_cleanup_columns(
125    existing: &std::collections::HashSet<String>,
126    scope_cols: &[String],
127    key: &[String],
128    table: &str,
129) -> Result<(), FaucetError> {
130    for col in scope_cols.iter().chain(key.iter()) {
131        if !existing.contains(col) {
132            return Err(FaucetError::Sink(format!(
133                "cleanup: column '{col}' does not exist on table '{table}' — the \
134                 completeness claim and `key` are in destination column terms"
135            )));
136        }
137    }
138    Ok(())
139}
140
141/// Bind one JSON value to a SQLite query as its native type.
142///
143/// Shared by the delete-by-key and scoped-cleanup paths so the two never drift:
144/// a key bound as a JSON string (`"7"` instead of `7`) would silently match
145/// nothing and turn a delete into a no-op.
146fn bind_value<'q>(
147    q: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
148    v: &Value,
149) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
150    match v {
151        Value::Null => q.bind(None::<String>),
152        Value::Bool(b) => q.bind(*b),
153        Value::Number(n) => {
154            if let Some(i) = n.as_i64() {
155                q.bind(i)
156            } else if let Some(f) = n.as_f64() {
157                q.bind(f)
158            } else {
159                // u64 above i64::MAX — preserve exact text.
160                q.bind(n.to_string())
161            }
162        }
163        Value::String(s) => q.bind(s.clone()),
164        // Arrays/objects have no scalar SQL representation — bind their JSON
165        // text (suitable for TEXT columns).
166        other => q.bind(other.to_string()),
167    }
168}
169
170/// Map a [`SqlBaseType`] to the SQLite column-type keyword used when adding a
171/// column during schema evolution (issue #194). SQLite uses dynamic typing
172/// (type affinity), so these are advisory affinities rather than strict types:
173/// `Boolean` maps to `INTEGER` (SQLite has no native boolean) and `Json` to
174/// `TEXT` (JSON is stored as text).
175fn sqlite_keyword(t: SqlBaseType) -> &'static str {
176    match t {
177        SqlBaseType::Integer => "INTEGER",
178        SqlBaseType::Double => "REAL",
179        SqlBaseType::Boolean => "INTEGER",
180        SqlBaseType::Text => "TEXT",
181        SqlBaseType::Json => "TEXT",
182    }
183}
184
185/// `ALTER TABLE <table> ADD COLUMN "<col>" <kw>` — SQLite has no
186/// `ADD COLUMN IF NOT EXISTS`, so [`SqliteSink::evolve_schema`] only emits this
187/// for columns it has already verified are absent (idempotency by pre-check).
188/// `table` is the unquoted table name; it is quoted here via [`quote_ident`].
189fn build_add_column_sql(table: &str, col: &str, t: SqlBaseType) -> String {
190    format!(
191        "ALTER TABLE {} ADD COLUMN {} {}",
192        quote_ident(table),
193        quote_ident(col),
194        sqlite_keyword(t)
195    )
196}
197
198/// Map a SQLite column affinity string (`PRAGMA table_info.type`, e.g. `INTEGER`,
199/// `REAL`, `VARCHAR(255)`, `TEXT`) to a JSON-Schema type fragment so
200/// [`SqliteSink::current_schema`] round-trips with [`faucet_core::diff_schema`].
201///
202/// SQLite determines affinity by a tolerant, case-insensitive substring match on
203/// the declared type (the rules in <https://www.sqlite.org/datatype3.html>), so
204/// this mirrors that: contains `INT` → integer; `CHAR`/`CLOB`/`TEXT` → string;
205/// `REAL`/`FLOA`/`DOUB` (and the loose `NUMERIC`/`DECIMAL`) → number; everything
206/// else falls back to string. `nullable` reflects `PRAGMA table_info.notnull == 0`.
207fn sqlite_affinity_to_json_schema(declared: &str, nullable: bool) -> serde_json::Value {
208    let up = declared.to_ascii_uppercase();
209    let contains = |needle: &str| up.contains(needle);
210    let base = if contains("INT") {
211        "integer"
212    } else if contains("CHAR") || contains("CLOB") || contains("TEXT") {
213        "string"
214    } else if contains("REAL")
215        || contains("FLOA")
216        || contains("DOUB")
217        || contains("NUMERIC")
218        || contains("DECIMAL")
219    {
220        "number"
221    } else {
222        "string"
223    };
224    if nullable {
225        serde_json::json!({ "type": [base, "null"] })
226    } else {
227        serde_json::json!({ "type": base })
228    }
229}
230
231/// Build the `ON CONFLICT(key) DO UPDATE …` tail for an upsert INSERT.
232/// Non-key columns are SET from `excluded`. If every column is a key column,
233/// emit `DO NOTHING`.
234fn on_conflict_clause(key: &[String], all_cols: &[String]) -> String {
235    let key_list = key
236        .iter()
237        .map(|k| quote_ident(k))
238        .collect::<Vec<_>>()
239        .join(", ");
240    let updates: Vec<String> = all_cols
241        .iter()
242        .filter(|c| !key.iter().any(|k| k == *c))
243        .map(|c| format!("{q} = excluded.{q}", q = quote_ident(c)))
244        .collect();
245    if updates.is_empty() {
246        format!("ON CONFLICT({key_list}) DO NOTHING")
247    } else {
248        format!(
249            "ON CONFLICT({key_list}) DO UPDATE SET {}",
250            updates.join(", ")
251        )
252    }
253}
254
255/// A sink that writes JSON records to a SQLite table.
256pub struct SqliteSink {
257    config: SqliteSinkConfig,
258    pool: SqlitePool,
259}
260
261impl SqliteSink {
262    /// Create a new SQLite sink. Establishes a connection pool.
263    ///
264    /// The pool opens each connection with `journal_mode = WAL` and a 5-second
265    /// `busy_timeout`. WAL lets a writer and readers proceed concurrently
266    /// instead of locking each other out, and the busy timeout makes a
267    /// connection wait-and-retry for the write lock rather than failing
268    /// immediately with `SQLITE_BUSY` under contention. `create_if_missing`
269    /// preserves the previous behaviour of creating the database file on first
270    /// open. WAL on a `sqlite::memory:` database is a harmless no-op.
271    pub async fn new(config: SqliteSinkConfig) -> Result<Self, FaucetError> {
272        config.write.validate()?;
273        if !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
274            && !matches!(config.column_mapping, SqliteColumnMapping::AutoMap)
275        {
276            return Err(FaucetError::Config(
277                "sqlite sink: write_mode upsert/delete requires column_mapping: auto_map \
278                 (key columns must be real columns, not inside a JSON blob)"
279                    .into(),
280            ));
281        }
282
283        let options = SqliteConnectOptions::from_str(&config.database_url)
284            .map_err(|e| FaucetError::Sink(format!("invalid SQLite database_url: {e}")))?
285            .create_if_missing(true)
286            .journal_mode(SqliteJournalMode::Wal)
287            .busy_timeout(Duration::from_secs(5));
288
289        let pool = SqlitePoolOptions::new()
290            .max_connections(config.max_connections)
291            .connect_with(options)
292            .await
293            .map_err(|e| FaucetError::Sink(format!("SQLite connection failed: {e}")))?;
294
295        Ok(Self { config, pool })
296    }
297
298    /// Insert JSON-column records within an existing transaction, sub-chunking
299    /// at SQLite's bind-variable cap. JSON mode binds one variable per row.
300    async fn insert_json_tx(
301        &self,
302        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
303        records: &[Value],
304        column: &str,
305    ) -> Result<usize, FaucetError> {
306        if records.is_empty() {
307            return Ok(0);
308        }
309        // SQLite caps bind params per statement at 32766 (>=3.32). JSON mode
310        // binds one variable per row, so chunk at that cap.
311        const MAX_SQLITE_VARS: usize = 32766;
312        for chunk in records.chunks(MAX_SQLITE_VARS) {
313            let placeholders: Vec<&str> = chunk.iter().map(|_| "(?)").collect();
314            let insert_sql = format!(
315                "INSERT INTO {} ({}) VALUES {}",
316                quote_ident(&self.config.table_name),
317                quote_ident(column),
318                placeholders.join(", ")
319            );
320            let mut q = sqlx::query(&insert_sql);
321            for record in chunk {
322                let json_str = serde_json::to_string(record)
323                    .map_err(|e| FaucetError::Sink(format!("failed to serialize record: {e}")))?;
324                q = q.bind(json_str);
325            }
326            q.execute(&mut **tx)
327                .await
328                .map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
329        }
330        Ok(records.len())
331    }
332
333    /// Insert a batch of records using JSON column mode.
334    /// Opens its own `BEGIN`/`COMMIT` transaction and delegates to
335    /// [`Self::insert_json_tx`], which sub-chunks at SQLite's bind-variable cap.
336    async fn insert_json(&self, records: &[Value], column: &str) -> Result<usize, FaucetError> {
337        if records.is_empty() {
338            return Ok(0);
339        }
340        let mut tx = self
341            .pool
342            .begin()
343            .await
344            .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
345        let n = self.insert_json_tx(&mut tx, records, column).await?;
346        tx.commit()
347            .await
348            .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
349        Ok(n)
350    }
351
352    /// Insert a batch of records using auto-mapped columns.
353    ///
354    /// Discovers column names from `pragma_table_info` and maps
355    /// top-level JSON fields to columns. Uses a single multi-row INSERT
356    /// wrapped in a transaction.
357    async fn insert_auto_map(&self, records: &[Value]) -> Result<usize, FaucetError> {
358        if records.is_empty() {
359            return Ok(0);
360        }
361
362        let mut tx = self
363            .pool
364            .begin()
365            .await
366            .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
367
368        let written = self.insert_auto_map_tx(&mut tx, records).await?;
369
370        tx.commit()
371            .await
372            .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
373
374        Ok(written)
375    }
376
377    /// Auto-map insert against an in-progress transaction.
378    ///
379    /// This is the reusable core shared by [`Self::insert_auto_map`] (which
380    /// opens its own `BEGIN`/`COMMIT`) and [`faucet_core::Sink::write_batch_idempotent`]
381    /// (which folds the insert and the commit-token upsert into one
382    /// transaction). The read-only `PRAGMA table_info` column-discovery query
383    /// runs on the transaction's own connection (`&mut **tx`), not on
384    /// `&self.pool` — otherwise, with the default single-connection pool, it
385    /// would deadlock waiting for a connection the open transaction is holding.
386    ///
387    /// When `conflict_key` is `Some(key)`, each sub-chunk's INSERT is given an
388    /// `ON CONFLICT(key) DO UPDATE …` tail so it upserts by the key columns
389    /// (last-write-wins within the batch is handled by the planner's dedup,
390    /// so a single sub-chunk never double-hits the same conflict target).
391    async fn insert_auto_map_with_conflict_tx(
392        &self,
393        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
394        records: &[Value],
395        conflict_key: Option<&[String]>,
396    ) -> Result<usize, FaucetError> {
397        if records.is_empty() {
398            return Ok(0);
399        }
400
401        // Get column names from the table using pragma_table_info. Use the
402        // transaction's connection so a single-connection pool doesn't deadlock.
403        let columns: Vec<String> = sqlx::query(&format!(
404            "PRAGMA table_info({})",
405            quote_ident(&self.config.table_name)
406        ))
407        .fetch_all(&mut **tx)
408        .await
409        .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
410        .iter()
411        .map(|row| row.get::<String, _>("name"))
412        .collect();
413
414        if columns.is_empty() {
415            return Err(FaucetError::Sink(format!(
416                "table '{}' has no columns or does not exist",
417                self.config.table_name
418            )));
419        }
420
421        // Pre-validate all records and collect matched column values. The
422        // INSERT column set is the UNION of table columns present in ANY record
423        // (in declared table order), not just the first record's keys —
424        // otherwise a field present only in a later record of the batch would be
425        // silently dropped (audit #146 H1). A row missing a unioned column binds
426        // SQL NULL.
427        let mut matched_rows: Vec<Vec<(&String, &Value)>> = Vec::with_capacity(records.len());
428        let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
429
430        for record in records {
431            let obj = record
432                .as_object()
433                .ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
434
435            let matching: Vec<(&String, &Value)> = columns
436                .iter()
437                .filter_map(|col| obj.get(col).map(|v| (col, v)))
438                .collect();
439
440            if matching.is_empty() {
441                tracing::warn!(
442                    record_keys = ?obj.keys().collect::<Vec<_>>(),
443                    table_columns = ?columns,
444                    "record has no keys matching table columns, skipping"
445                );
446                continue;
447            }
448
449            for (c, _) in &matching {
450                used.insert(c.as_str());
451            }
452            matched_rows.push(matching);
453        }
454
455        if matched_rows.is_empty() {
456            return Ok(0);
457        }
458
459        // Table columns (in declared order) that appear in at least one record.
460        let insert_columns: Vec<String> = columns
461            .iter()
462            .filter(|c| used.contains(c.as_str()))
463            .cloned()
464            .collect();
465
466        let num_cols = insert_columns.len();
467        let num_rows = matched_rows.len();
468        let col_names: Vec<String> = insert_columns.iter().map(|c| quote_ident(c)).collect();
469
470        // SQLite caps bind parameters per statement at SQLITE_MAX_VARIABLE_NUMBER
471        // (32766 since 3.32). A multi-row INSERT binds `rows × num_cols`
472        // parameters, so a wide table at a large batch_size can exceed it and
473        // fail at runtime with "too many SQL variables" (#78/#21). Split into
474        // sub-INSERTs of at most floor(MAX_VARS / num_cols) rows.
475        const MAX_SQLITE_VARS: usize = 32766;
476        let max_rows_per_insert = (MAX_SQLITE_VARS / num_cols).max(1);
477
478        for sub in matched_rows.chunks(max_rows_per_insert) {
479            // Build multi-row VALUES clause: (?, ?), (?, ?), ...
480            let row_placeholder = format!("({})", vec!["?"; num_cols].join(", "));
481            let value_tuples: Vec<&str> =
482                (0..sub.len()).map(|_| row_placeholder.as_str()).collect();
483            let base_query = format!(
484                "INSERT INTO {} ({}) VALUES {}",
485                quote_ident(&self.config.table_name),
486                col_names.join(", "),
487                value_tuples.join(", ")
488            );
489            let query = match conflict_key {
490                Some(key) => format!("{base_query} {}", on_conflict_clause(key, &insert_columns)),
491                None => base_query,
492            };
493
494            let mut q = sqlx::query(&query);
495            for matched in sub {
496                for col in &insert_columns {
497                    let val = matched.iter().find(|(c, _)| *c == col).map(|(_, v)| *v);
498                    // Bind native SQLite types so column affinity and typed reads
499                    // round-trip correctly. Binding every value as a JSON string
500                    // (the old behaviour) stored `"Bob"` with embedded quotes,
501                    // turned `true` into the text "true", and bound the literal
502                    // text "null" for absent columns instead of SQL NULL (#78/#4).
503                    q = match val {
504                        None | Some(Value::Null) => q.bind(None::<String>),
505                        Some(Value::Bool(b)) => q.bind(*b),
506                        Some(Value::Number(n)) => {
507                            if let Some(i) = n.as_i64() {
508                                q.bind(i)
509                            } else if let Some(f) = n.as_f64() {
510                                q.bind(f)
511                            } else {
512                                // u64 above i64::MAX — preserve exact text.
513                                q.bind(n.to_string())
514                            }
515                        }
516                        Some(Value::String(s)) => q.bind(s.clone()),
517                        // Arrays/objects have no scalar SQL representation — store
518                        // their JSON text (suitable for TEXT / JSON columns).
519                        Some(v) => q.bind(v.to_string()),
520                    };
521                }
522            }
523
524            q.execute(&mut **tx)
525                .await
526                .map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
527        }
528
529        Ok(num_rows)
530    }
531
532    /// Auto-map insert against an in-progress transaction with plain append
533    /// semantics (no `ON CONFLICT` tail).
534    ///
535    /// Thin wrapper over
536    /// [`insert_auto_map_with_conflict_tx`](Self::insert_auto_map_with_conflict_tx)
537    /// so the append path and the idempotent-write path keep their original
538    /// signature.
539    async fn insert_auto_map_tx(
540        &self,
541        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
542        records: &[Value],
543    ) -> Result<usize, FaucetError> {
544        self.insert_auto_map_with_conflict_tx(tx, records, None)
545            .await
546    }
547
548    /// Delete rows whose key columns match any of `deletes`, using
549    /// `DELETE FROM t WHERE (k1, …) IN ((?, …), …)`, chunked at
550    /// SQLite's bind-variable cap. Runs inside the caller's transaction.
551    async fn delete_by_keys(
552        &self,
553        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
554        deletes: &[faucet_core::KeyTuple],
555    ) -> Result<usize, FaucetError> {
556        if deletes.is_empty() {
557            return Ok(0);
558        }
559        let key = &self.config.write.key;
560        let table_ref = quote_ident(&self.config.table_name);
561        let col_list = key
562            .iter()
563            .map(|k| quote_ident(k))
564            .collect::<Vec<_>>()
565            .join(", ");
566
567        const MAX_SQLITE_VARS: usize = 32766;
568        let per = (MAX_SQLITE_VARS / key.len().max(1)).max(1);
569        let mut total = 0usize;
570
571        for chunk in deletes.chunks(per) {
572            let tuples: Vec<String> = chunk
573                .iter()
574                .map(|_| format!("({})", vec!["?"; key.len()].join(", ")))
575                .collect();
576            let sql = format!(
577                "DELETE FROM {table_ref} WHERE ({col_list}) IN ({})",
578                tuples.join(", ")
579            );
580            let mut q = sqlx::query(&sql);
581            for kt in chunk {
582                for (_, v) in &kt.0 {
583                    // Bind native SQLite types — same logic as in the INSERT path.
584                    q = bind_value(q, v);
585                }
586            }
587            let res = q
588                .execute(&mut **tx)
589                .await
590                .map_err(|e| FaucetError::Sink(format!("SQLite delete failed: {e}")))?;
591            total += res.rows_affected() as usize;
592        }
593        Ok(total)
594    }
595
596    /// Apply a planned upsert/delete batch inside one `BEGIN`/`COMMIT`
597    /// transaction. Upserts and deletes are wrapped together so they commit
598    /// atomically.
599    async fn apply_plan(&self, plan: &faucet_core::WritePlan) -> Result<usize, FaucetError> {
600        let mut tx = self
601            .pool
602            .begin()
603            .await
604            .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
605
606        let mut affected = 0usize;
607        if !plan.upserts.is_empty() {
608            affected += self
609                .insert_auto_map_with_conflict_tx(
610                    &mut tx,
611                    &plan.upserts,
612                    Some(&self.config.write.key),
613                )
614                .await?;
615        }
616        if !plan.deletes.is_empty() {
617            affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
618        }
619
620        tx.commit()
621            .await
622            .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
623        Ok(affected)
624    }
625
626    /// Delete rows in `scope` whose key was not written by this run (#478).
627    ///
628    /// Uses a temp table + `NOT EXISTS` rather than `key NOT IN (…)` because the
629    /// written-key set routinely exceeds SQLite's 32766 bind-variable limit (the
630    /// cleanup ceiling defaults to 100k rows). It also makes the whole thing one
631    /// transaction, so the delete is all-or-nothing: a partial delete would
632    /// remove rows the run actually wrote.
633    ///
634    /// An empty `seen` set is meaningful, not a no-op — it means the source
635    /// reported the scope as empty, so every row in it is stale and must go. That
636    /// is the case this feature exists for, and `NOT EXISTS` against an empty
637    /// table handles it without a special branch.
638    ///
639    /// Every statement runs on the transaction's own connection: the temp table
640    /// lives in that connection's `temp` schema, and with the default
641    /// single-connection pool any query sent to `&self.pool` instead would
642    /// deadlock waiting for the connection the open transaction is holding.
643    async fn cleanup_scope_impl(
644        &self,
645        scope: &std::collections::BTreeMap<String, Value>,
646        seen: &faucet_core::SeenKeys,
647    ) -> Result<u64, FaucetError> {
648        let key = &self.config.write.key;
649        if key.is_empty() {
650            return Err(FaucetError::Sink(
651                "cleanup requires a non-empty `key`".to_string(),
652            ));
653        }
654        let table = &self.config.table_name;
655
656        let mut tx = self
657            .pool
658            .begin()
659            .await
660            .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
661
662        // Live column set + declared types, from the table this DELETE targets.
663        let declared: std::collections::HashMap<String, String> =
664            sqlx::query(&format!("PRAGMA table_info({})", quote_ident_sqlite(table)))
665                .fetch_all(&mut *tx)
666                .await
667                .map_err(|e| FaucetError::Sink(format!("cleanup: table_info query failed: {e}")))?
668                .iter()
669                .map(|row| (row.get::<String, _>("name"), row.get::<String, _>("type")))
670                .collect();
671
672        let scope_cols: Vec<String> = scope.keys().cloned().collect();
673        let existing: std::collections::HashSet<String> = declared.keys().cloned().collect();
674        validate_cleanup_columns(&existing, &scope_cols, key, table)?;
675
676        // A previous cleanup that failed *after* its COMMIT-less DROP could not
677        // leave the table behind (SQLite rolls DDL back), but the pooled
678        // connection is shared, so drop defensively before creating.
679        let keys_ref = cleanup_keys_ref();
680        sqlx::query(&format!("DROP TABLE IF EXISTS {keys_ref}"))
681            .execute(&mut *tx)
682            .await
683            .map_err(|e| FaucetError::Sink(format!("cleanup: temp table drop failed: {e}")))?;
684
685        let key_types: Vec<(String, String)> = key
686            .iter()
687            .map(|k| (k.clone(), declared.get(k).cloned().unwrap_or_default()))
688            .collect();
689        sqlx::query(&build_cleanup_temp_table_sql(&key_types))
690            .execute(&mut *tx)
691            .await
692            .map_err(|e| FaucetError::Sink(format!("cleanup: temp table creation failed: {e}")))?;
693
694        // Load the written keys, chunked at SQLite's bind-variable cap.
695        const MAX_SQLITE_VARS: usize = 32766;
696        let per = (MAX_SQLITE_VARS / key.len()).max(1);
697        for chunk in seen.keys().chunks(per) {
698            let sql = build_cleanup_insert_sql(key, chunk.len());
699            let mut q = sqlx::query(&sql);
700            for kt in chunk {
701                for (_, v) in &kt.0 {
702                    q = bind_value(q, v);
703                }
704            }
705            q.execute(&mut *tx)
706                .await
707                .map_err(|e| FaucetError::Sink(format!("cleanup: loading keys failed: {e}")))?;
708        }
709
710        // DELETE everything in scope that isn't in the written-key set.
711        let sql = build_cleanup_delete_sql(table, &scope_cols, key);
712        let mut q = sqlx::query(&sql);
713        for v in scope.values() {
714            q = bind_value(q, v);
715        }
716        let res = q
717            .execute(&mut *tx)
718            .await
719            .map_err(|e| FaucetError::Sink(format!("cleanup: delete failed: {e}")))?;
720
721        // Drop inside the transaction so the pooled connection goes back clean.
722        sqlx::query(&format!("DROP TABLE IF EXISTS {keys_ref}"))
723            .execute(&mut *tx)
724            .await
725            .map_err(|e| FaucetError::Sink(format!("cleanup: temp table drop failed: {e}")))?;
726
727        tx.commit()
728            .await
729            .map_err(|e| FaucetError::Sink(format!("cleanup: commit failed: {e}")))?;
730        Ok(res.rows_affected())
731    }
732
733    /// Ensure the commit-token watermark table exists.
734    async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
735        let sql = format!(
736            "CREATE TABLE IF NOT EXISTS {t} ({s} TEXT PRIMARY KEY, {k} TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')))",
737            t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
738            s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
739            k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
740        );
741        sqlx::query(&sql)
742            .execute(&self.pool)
743            .await
744            .map_err(|e| FaucetError::Sink(format!("SQLite commit-table create failed: {e}")))?;
745        Ok(())
746    }
747}
748
749#[async_trait]
750impl faucet_core::Sink for SqliteSink {
751    fn connector_name(&self) -> &'static str {
752        "sqlite"
753    }
754
755    fn config_schema(&self) -> serde_json::Value {
756        serde_json::to_value(faucet_core::schema_for!(SqliteSinkConfig))
757            .expect("schema serialization")
758    }
759
760    fn dataset_uri(&self) -> String {
761        let path = self
762            .config
763            .database_url
764            .trim_start_matches("sqlite://")
765            .trim_start_matches("sqlite:");
766        format!("sqlite://{}?table={}", path, self.config.table_name)
767    }
768
769    /// Preflight connectivity probe (`faucet doctor`).
770    ///
771    /// Acquires a connection from the existing pool and runs `SELECT 1`. This
772    /// is non-mutating and idempotent — it validates that the database file /
773    /// connection opens without writing anything.
774    async fn check(
775        &self,
776        ctx: &faucet_core::check::CheckContext,
777    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
778        use faucet_core::check::{CheckReport, Probe};
779
780        let started = std::time::Instant::now();
781        let probe =
782            match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
783                .await
784            {
785                Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
786                Ok(Err(e)) => Probe::fail_hint(
787                    "auth",
788                    started.elapsed(),
789                    e.to_string(),
790                    "check database_url / that the database file is reachable and openable",
791                ),
792                Err(_) => Probe::fail_hint(
793                    "auth",
794                    started.elapsed(),
795                    "timed out",
796                    "check database_url / that the database file is reachable and openable",
797                ),
798            };
799        Ok(CheckReport::single(probe))
800    }
801
802    fn supports_cleanup(&self) -> bool {
803        // Column-mapping mode only: the scope + key predicates address real
804        // columns, which a single JSON payload column does not have.
805        matches!(self.config.column_mapping, SqliteColumnMapping::AutoMap)
806    }
807
808    async fn cleanup_scope(
809        &self,
810        scope: &std::collections::BTreeMap<String, Value>,
811        seen: &faucet_core::SeenKeys,
812    ) -> Result<u64, FaucetError> {
813        self.cleanup_scope_impl(scope, seen).await
814    }
815
816    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
817        &[
818            faucet_core::WriteMode::Append,
819            faucet_core::WriteMode::Upsert,
820            faucet_core::WriteMode::Delete,
821        ]
822    }
823
824    fn dedups_by_key(&self) -> bool {
825        self.config.write.dedups_by_key()
826    }
827
828    fn supports_schema_evolution(&self) -> bool {
829        true
830    }
831
832    /// Read the live destination schema via `PRAGMA table_info`, shaped as an
833    /// `infer_schema`-compatible object (`{"type":"object","properties":{…}}`),
834    /// or `None` when the target table does not exist yet (issue #194).
835    ///
836    /// `PRAGMA table_info` returns one row per column with `name`, `type` (the
837    /// declared affinity string), and `notnull`. The affinity string is mapped
838    /// to a JSON-Schema base type via `sqlite_affinity_to_json_schema`, and
839    /// `notnull == 0` surfaces the column as nullable. The PRAGMA runs on a
840    /// connection acquired from the pool (a standalone read — not inside an open
841    /// transaction).
842    async fn current_schema(&self) -> Result<Option<serde_json::Value>, FaucetError> {
843        let rows = sqlx::query(&format!(
844            "PRAGMA table_info({})",
845            quote_ident(&self.config.table_name)
846        ))
847        .fetch_all(&self.pool)
848        .await
849        .map_err(|e| FaucetError::Sink(format!("sqlite current_schema query failed: {e}")))?;
850
851        if rows.is_empty() {
852            return Ok(None); // table does not exist yet (or has no columns)
853        }
854
855        let mut props = serde_json::Map::new();
856        for row in &rows {
857            let name: String = row.get("name");
858            let declared: String = row.get("type");
859            let notnull: i64 = row.get("notnull");
860            props.insert(
861                name,
862                sqlite_affinity_to_json_schema(&declared, notnull == 0),
863            );
864        }
865        Ok(Some(
866            serde_json::json!({ "type": "object", "properties": props }),
867        ))
868    }
869
870    /// Apply an additive schema evolution to the destination table (issue #194).
871    ///
872    /// - **Additions** — `ALTER TABLE … ADD COLUMN`. SQLite has no
873    ///   `ADD COLUMN IF NOT EXISTS`, so the current columns are read first and a
874    ///   column already present is silently skipped (idempotency by pre-check).
875    /// - **Widenings** — a no-op under SQLite's dynamic typing: a column already
876    ///   accepts a value of any type, so there is nothing to ALTER. Logged once
877    ///   at `debug`.
878    /// - **Nullability relaxations** — a no-op: SQLite cannot drop a `NOT NULL`
879    ///   constraint in place (it requires a full table rebuild, which is out of
880    ///   scope here). Logged once at `debug`; the column is left as-is.
881    async fn evolve_schema(&self, evolution: &SchemaEvolution) -> Result<(), FaucetError> {
882        // Read the current column set so additions are idempotent (no
883        // `ADD COLUMN IF NOT EXISTS` in SQLite).
884        let existing: std::collections::HashSet<String> = sqlx::query(&format!(
885            "PRAGMA table_info({})",
886            quote_ident(&self.config.table_name)
887        ))
888        .fetch_all(&self.pool)
889        .await
890        .map_err(|e| FaucetError::Sink(format!("sqlite evolve table_info failed: {e}")))?
891        .iter()
892        .map(|row| row.get::<String, _>("name"))
893        .collect();
894
895        for c in &evolution.additions {
896            if existing.contains(&c.name) {
897                continue; // already present — ADD COLUMN would error
898            }
899            let t = json_schema_base_type(&c.to).unwrap_or(SqlBaseType::Text);
900            sqlx::query(&build_add_column_sql(&self.config.table_name, &c.name, t))
901                .execute(&self.pool)
902                .await
903                .map_err(|e| {
904                    FaucetError::Sink(format!("sqlite ADD COLUMN {} failed: {e}", c.name))
905                })?;
906        }
907
908        if !evolution.widenings.is_empty() {
909            tracing::debug!("sqlite: type widening is a no-op under dynamic typing");
910        }
911        for col in &evolution.relax_nullability {
912            tracing::debug!("sqlite cannot relax NOT NULL in place; column {col} left as-is");
913        }
914
915        Ok(())
916    }
917
918    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
919        if records.is_empty() {
920            return Ok(0);
921        }
922
923        // Non-append modes: plan the writes and apply atomically.
924        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
925            let plan = faucet_core::plan_writes(records, &self.config.write);
926            if let Some((idx, msg)) = plan.failed.first() {
927                return Err(FaucetError::Sink(format!(
928                    "sqlite {}: row {idx}: {msg}",
929                    self.config.write.write_mode.as_str()
930                )));
931            }
932            return self.apply_plan(&plan).await;
933        }
934
935        // `batch_size = 0` is the "no batching" sentinel: write the entire
936        // upstream slice as a single multi-row INSERT inside one
937        // `BEGIN`/`COMMIT` transaction, preserving `StreamPage` framing.
938        // Otherwise re-chunk into `batch_size` slices so each transaction
939        // stays near SQLite's sweet spot (~1000 rows per multi-row INSERT).
940        let effective_chunk = if self.config.batch_size == 0 {
941            records.len()
942        } else {
943            self.config.batch_size
944        };
945
946        let mut total = 0;
947        for chunk in records.chunks(effective_chunk) {
948            total += match &self.config.column_mapping {
949                SqliteColumnMapping::Json { column } => self.insert_json(chunk, column).await?,
950                SqliteColumnMapping::AutoMap => self.insert_auto_map(chunk).await?,
951            };
952        }
953
954        tracing::info!(
955            table = %self.config.table_name,
956            rows = total,
957            "SQLite write complete"
958        );
959        Ok(total)
960    }
961
962    /// Write a batch and report per-row outcomes.
963    ///
964    /// In append mode this delegates to [`write_batch`](faucet_core::Sink::write_batch) and
965    /// maps a single success onto an all-`Ok(())` vector (the trait default).
966    /// In upsert/delete mode the good rows are applied (upserts + deletes), and
967    /// only the rows whose key could not be extracted (missing / null key) are
968    /// reported as `Err` so the pipeline routes them to the DLQ per-row instead
969    /// of sending the whole page.
970    async fn write_batch_partial(
971        &self,
972        records: &[Value],
973    ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
974        if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
975            self.write_batch(records).await?;
976            return Ok(records.iter().map(|_| Ok(())).collect());
977        }
978
979        let plan = faucet_core::plan_writes(records, &self.config.write);
980        self.apply_plan(&plan).await?;
981
982        let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
983        for (idx, msg) in &plan.failed {
984            outcomes[*idx] = Err(FaucetError::Sink(format!(
985                "sqlite {}: {msg}",
986                self.config.write.write_mode.as_str()
987            )));
988        }
989        Ok(outcomes)
990    }
991
992    fn supports_idempotent_writes(&self) -> bool {
993        true
994    }
995
996    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
997        self.ensure_commit_table().await?;
998        let sql = format!(
999            "SELECT {k} FROM {t} WHERE {s} = ?",
1000            t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
1001            k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
1002            s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
1003        );
1004        let row = sqlx::query(&sql)
1005            .bind(scope)
1006            .fetch_optional(&self.pool)
1007            .await
1008            .map_err(|e| FaucetError::Sink(format!("SQLite token read failed: {e}")))?;
1009        Ok(row.map(|r| r.get::<String, _>(0)))
1010    }
1011
1012    async fn write_batch_idempotent(
1013        &self,
1014        records: &[Value],
1015        scope: &str,
1016        token: &str,
1017    ) -> Result<usize, FaucetError> {
1018        self.ensure_commit_table().await?;
1019
1020        // For upsert/delete modes, plan the page before opening the transaction
1021        // so a key-extraction failure aborts without leaving an open tx.
1022        let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
1023            None
1024        } else {
1025            let plan = faucet_core::plan_writes(records, &self.config.write);
1026            if let Some((idx, msg)) = plan.failed.first() {
1027                return Err(FaucetError::Sink(format!(
1028                    "sqlite {}: row {idx}: {msg}",
1029                    self.config.write.write_mode.as_str()
1030                )));
1031            }
1032            Some(plan)
1033        };
1034
1035        let mut tx = self
1036            .pool
1037            .begin()
1038            .await
1039            .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
1040
1041        // Data write and the commit-token upsert share ONE transaction so the
1042        // page is committed atomically with its watermark: on crash either both
1043        // land or neither does, which is what makes a replay skip-on-resume
1044        // produce zero duplicates. For upsert/delete the planned upserts/deletes
1045        // commit together with the watermark in this same tx (no nested tx —
1046        // we reuse `apply_plan`'s helpers directly on this transaction).
1047        let written = match &plan {
1048            Some(plan) => {
1049                let mut affected = 0usize;
1050                if !plan.upserts.is_empty() {
1051                    affected += self
1052                        .insert_auto_map_with_conflict_tx(
1053                            &mut tx,
1054                            &plan.upserts,
1055                            Some(&self.config.write.key),
1056                        )
1057                        .await?;
1058                }
1059                if !plan.deletes.is_empty() {
1060                    affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
1061                }
1062                affected
1063            }
1064            None => match &self.config.column_mapping {
1065                SqliteColumnMapping::Json { column } => {
1066                    self.insert_json_tx(&mut tx, records, column).await?
1067                }
1068                SqliteColumnMapping::AutoMap => self.insert_auto_map_tx(&mut tx, records).await?,
1069            },
1070        };
1071
1072        let upsert = format!(
1073            "INSERT INTO {t} ({s}, {k}) VALUES (?, ?) ON CONFLICT({s}) DO UPDATE SET {k} = excluded.{k}, updated_at = datetime('now')",
1074            t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
1075            s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
1076            k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
1077        );
1078        sqlx::query(&upsert)
1079            .bind(scope)
1080            .bind(token)
1081            .execute(&mut *tx)
1082            .await
1083            .map_err(|e| FaucetError::Sink(format!("SQLite token upsert failed: {e}")))?;
1084
1085        tx.commit()
1086            .await
1087            .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
1088        Ok(written)
1089    }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095    use crate::config::SqliteSinkConfig;
1096    use faucet_core::Sink as _;
1097
1098    #[tokio::test]
1099    async fn dataset_uri_strips_sqlite_prefix_and_includes_table() {
1100        let config = SqliteSinkConfig::new("sqlite:///tmp/test.db", "events");
1101        let sink = SqliteSink::new(config).await.unwrap();
1102        assert_eq!(sink.dataset_uri(), "sqlite:///tmp/test.db?table=events");
1103    }
1104
1105    #[tokio::test]
1106    async fn dataset_uri_with_memory_db() {
1107        let config = SqliteSinkConfig::new("sqlite::memory:", "logs");
1108        let sink = SqliteSink::new(config).await.unwrap();
1109        assert_eq!(sink.dataset_uri(), "sqlite://:memory:?table=logs");
1110    }
1111
1112    #[test]
1113    fn sqlite_on_conflict_clause() {
1114        let clause =
1115            on_conflict_clause(&["id".to_string()], &["id".to_string(), "name".to_string()]);
1116        assert_eq!(
1117            clause,
1118            r#"ON CONFLICT("id") DO UPDATE SET "name" = excluded."name""#
1119        );
1120    }
1121
1122    #[test]
1123    fn sqlite_on_conflict_all_keys_does_nothing() {
1124        let clause = on_conflict_clause(&["id".to_string()], &["id".to_string()]);
1125        assert_eq!(clause, r#"ON CONFLICT("id") DO NOTHING"#);
1126    }
1127
1128    #[test]
1129    fn sqlite_on_conflict_composite_key() {
1130        let clause = on_conflict_clause(
1131            &["a".to_string(), "b".to_string()],
1132            &["a".to_string(), "b".to_string(), "v".to_string()],
1133        );
1134        assert_eq!(
1135            clause,
1136            r#"ON CONFLICT("a", "b") DO UPDATE SET "v" = excluded."v""#
1137        );
1138    }
1139
1140    #[test]
1141    fn sqlite_add_column_ddl() {
1142        assert_eq!(
1143            build_add_column_sql("t", "email", SqlBaseType::Text),
1144            r#"ALTER TABLE "t" ADD COLUMN "email" TEXT"#
1145        );
1146        assert_eq!(
1147            build_add_column_sql("t", "age", SqlBaseType::Integer),
1148            r#"ALTER TABLE "t" ADD COLUMN "age" INTEGER"#
1149        );
1150        assert_eq!(
1151            build_add_column_sql("t", "score", SqlBaseType::Double),
1152            r#"ALTER TABLE "t" ADD COLUMN "score" REAL"#
1153        );
1154        // Boolean has no native SQLite type → INTEGER affinity; JSON → TEXT.
1155        assert_eq!(
1156            build_add_column_sql("t", "ok", SqlBaseType::Boolean),
1157            r#"ALTER TABLE "t" ADD COLUMN "ok" INTEGER"#
1158        );
1159        assert_eq!(
1160            build_add_column_sql("t", "meta", SqlBaseType::Json),
1161            r#"ALTER TABLE "t" ADD COLUMN "meta" TEXT"#
1162        );
1163    }
1164
1165    #[test]
1166    fn sqlite_keyword_mapping() {
1167        assert_eq!(sqlite_keyword(SqlBaseType::Integer), "INTEGER");
1168        assert_eq!(sqlite_keyword(SqlBaseType::Double), "REAL");
1169        assert_eq!(sqlite_keyword(SqlBaseType::Boolean), "INTEGER");
1170        assert_eq!(sqlite_keyword(SqlBaseType::Text), "TEXT");
1171        assert_eq!(sqlite_keyword(SqlBaseType::Json), "TEXT");
1172    }
1173
1174    // ---------------------------------------------------------------------
1175    // Scoped cleanup (#478) — SQL generation and column validation
1176    // ---------------------------------------------------------------------
1177
1178    fn cols(names: &[&str]) -> Vec<String> {
1179        names.iter().map(|s| s.to_string()).collect()
1180    }
1181
1182    #[test]
1183    fn cleanup_quotes_identifiers_with_backticks() {
1184        // Not double quotes: SQLite's double-quoted-string misfeature would turn
1185        // a typo'd column into a string literal instead of an error.
1186        assert_eq!(quote_ident_sqlite("id"), "`id`");
1187        assert_eq!(quote_ident_sqlite("ev`il"), "`ev``il`");
1188    }
1189
1190    #[test]
1191    fn cleanup_temp_table_mirrors_declared_types() {
1192        let sql = build_cleanup_temp_table_sql(&[
1193            ("id".to_string(), "INTEGER".to_string()),
1194            ("slug".to_string(), "VARCHAR(255)".to_string()),
1195        ]);
1196        assert_eq!(
1197            sql,
1198            "CREATE TEMP TABLE temp.`faucet_cleanup_keys` (`id` INTEGER, `slug` VARCHAR(255))"
1199        );
1200    }
1201
1202    #[test]
1203    fn cleanup_temp_table_omits_an_unusable_type() {
1204        // A typeless column is legal in SQLite; it only loses type affinity.
1205        let sql = build_cleanup_temp_table_sql(&[("id".to_string(), String::new())]);
1206        assert_eq!(sql, "CREATE TEMP TABLE temp.`faucet_cleanup_keys` (`id`)");
1207        // A declared type that isn't type-spec-shaped is dropped rather than
1208        // pasted into DDL.
1209        let sql = build_cleanup_temp_table_sql(&[("id".to_string(), "INT); DROP".to_string())]);
1210        assert_eq!(sql, "CREATE TEMP TABLE temp.`faucet_cleanup_keys` (`id`)");
1211    }
1212
1213    #[test]
1214    fn safe_type_spec_accepts_real_types_and_rejects_the_rest() {
1215        assert_eq!(safe_type_spec("DOUBLE PRECISION"), Some("DOUBLE PRECISION"));
1216        assert_eq!(safe_type_spec("DECIMAL(10, 2)"), Some("DECIMAL(10, 2)"));
1217        assert_eq!(safe_type_spec("  TEXT  "), Some("TEXT"));
1218        assert_eq!(safe_type_spec(""), None);
1219        assert_eq!(safe_type_spec("   "), None);
1220        assert_eq!(safe_type_spec("TEXT`"), None);
1221        assert_eq!(safe_type_spec("TEXT'"), None);
1222    }
1223
1224    #[test]
1225    fn cleanup_insert_emits_one_tuple_per_row() {
1226        let sql = build_cleanup_insert_sql(&cols(&["a", "b"]), 3);
1227        assert_eq!(
1228            sql,
1229            "INSERT INTO temp.`faucet_cleanup_keys` (`a`, `b`) VALUES (?, ?), (?, ?), (?, ?)"
1230        );
1231    }
1232
1233    #[test]
1234    fn cleanup_delete_ands_the_scope_and_excludes_written_keys() {
1235        let sql = build_cleanup_delete_sql("assoc", &cols(&["contact_id"]), &cols(&["id"]));
1236        assert_eq!(
1237            sql,
1238            "DELETE FROM `assoc` WHERE `assoc`.`contact_id` = ? \
1239             AND NOT EXISTS (SELECT 1 FROM temp.`faucet_cleanup_keys` c \
1240             WHERE c.`id` = `assoc`.`id`)"
1241        );
1242    }
1243
1244    #[test]
1245    fn cleanup_delete_composite_scope_and_key() {
1246        let sql =
1247            build_cleanup_delete_sql("t", &cols(&["tenant", "contact_id"]), &cols(&["a", "b"]));
1248        assert_eq!(
1249            sql,
1250            "DELETE FROM `t` WHERE `t`.`tenant` = ? AND `t`.`contact_id` = ? \
1251             AND NOT EXISTS (SELECT 1 FROM temp.`faucet_cleanup_keys` c \
1252             WHERE c.`a` = `t`.`a` AND c.`b` = `t`.`b`)"
1253        );
1254    }
1255
1256    #[test]
1257    fn cleanup_validation_names_a_missing_scope_column() {
1258        let existing: std::collections::HashSet<String> =
1259            cols(&["id", "name"]).into_iter().collect();
1260        let err = validate_cleanup_columns(&existing, &cols(&["contact_id"]), &cols(&["id"]), "t")
1261            .expect_err("unknown scope column must be refused");
1262        let msg = err.to_string();
1263        assert!(msg.contains("contact_id"), "{msg}");
1264        assert!(msg.contains("'t'"), "{msg}");
1265    }
1266
1267    #[test]
1268    fn cleanup_validation_names_a_missing_key_column() {
1269        let existing: std::collections::HashSet<String> =
1270            cols(&["contact_id"]).into_iter().collect();
1271        let err = validate_cleanup_columns(&existing, &cols(&["contact_id"]), &cols(&["id"]), "t")
1272            .expect_err("unknown key column must be refused");
1273        assert!(err.to_string().contains("id"), "{err}");
1274    }
1275
1276    #[test]
1277    fn cleanup_validation_passes_when_every_column_exists() {
1278        let existing: std::collections::HashSet<String> =
1279            cols(&["id", "contact_id"]).into_iter().collect();
1280        assert!(
1281            validate_cleanup_columns(&existing, &cols(&["contact_id"]), &cols(&["id"]), "t")
1282                .is_ok()
1283        );
1284    }
1285
1286    #[tokio::test]
1287    async fn supports_cleanup_only_in_auto_map_mode() {
1288        let config = SqliteSinkConfig::new("sqlite::memory:", "t")
1289            .column_mapping(SqliteColumnMapping::AutoMap);
1290        let sink = SqliteSink::new(config).await.unwrap();
1291        assert!(sink.supports_cleanup());
1292
1293        // The default mapping is a single JSON payload column — no real columns
1294        // for the scope/key predicates to address.
1295        let config = SqliteSinkConfig::new("sqlite::memory:", "t");
1296        let sink = SqliteSink::new(config).await.unwrap();
1297        assert!(!sink.supports_cleanup());
1298    }
1299
1300    #[test]
1301    fn sqlite_affinity_round_trips_to_json_schema() {
1302        use serde_json::json;
1303        // Tolerant case-insensitive substring matching, SQLite affinity rules.
1304        assert_eq!(
1305            sqlite_affinity_to_json_schema("INTEGER", false),
1306            json!({"type":"integer"})
1307        );
1308        assert_eq!(
1309            sqlite_affinity_to_json_schema("BIGINT", false),
1310            json!({"type":"integer"})
1311        );
1312        assert_eq!(
1313            sqlite_affinity_to_json_schema("REAL", false),
1314            json!({"type":"number"})
1315        );
1316        assert_eq!(
1317            sqlite_affinity_to_json_schema("DOUBLE PRECISION", false),
1318            json!({"type":"number"})
1319        );
1320        assert_eq!(
1321            sqlite_affinity_to_json_schema("DECIMAL(10,2)", false),
1322            json!({"type":"number"})
1323        );
1324        assert_eq!(
1325            sqlite_affinity_to_json_schema("TEXT", false),
1326            json!({"type":"string"})
1327        );
1328        assert_eq!(
1329            sqlite_affinity_to_json_schema("VARCHAR(255)", false),
1330            json!({"type":"string"})
1331        );
1332        // Unknown / empty affinity falls back to string.
1333        assert_eq!(
1334            sqlite_affinity_to_json_schema("BLOB", false),
1335            json!({"type":"string"})
1336        );
1337        assert_eq!(
1338            sqlite_affinity_to_json_schema("", false),
1339            json!({"type":"string"})
1340        );
1341        // Nullable columns widen the type array.
1342        assert_eq!(
1343            sqlite_affinity_to_json_schema("integer", true),
1344            json!({"type":["integer","null"]})
1345        );
1346    }
1347}