Skip to main content

stateset_db/sqlite/
mod.rs

1//! SQLite database implementation
2
3mod a2a;
4mod accounts_payable;
5mod accounts_receivable;
6mod activity_logs;
7mod agent_cards;
8mod agent_identities;
9mod agent_reputation;
10mod agent_validation;
11mod analytics;
12mod backorder;
13mod bom;
14mod carts;
15mod channels;
16mod companies;
17mod cost_accounting;
18mod credit;
19mod currency;
20mod custom_objects;
21mod customers;
22mod edi_documents;
23mod fixed_assets;
24mod fraud;
25mod fulfillment;
26mod general_ledger;
27mod gift_cards;
28mod http_idempotency;
29mod inbound_shipments;
30mod integration_field_mappings;
31mod integration_mappings;
32mod inventory;
33mod invoices;
34mod lots;
35mod loyalty;
36mod money_agg;
37mod orders;
38pub(crate) mod parse_helpers;
39mod payment_obligations;
40mod payments;
41mod prepayments;
42mod price_levels;
43mod price_schedules;
44mod print_stations;
45mod production_batches;
46mod products;
47mod promotions;
48mod purchase_orders;
49mod purgatory;
50mod quality;
51mod receiving;
52mod returns;
53mod revenue_recognition;
54mod reviews;
55mod rewards;
56mod search_configs;
57mod segments;
58mod serials;
59mod shipments;
60mod shipping_zones;
61mod stock_snapshots;
62mod store_credits;
63mod subscriptions;
64mod supplier_skus;
65mod tax;
66mod topology_snapshots;
67mod transfer_orders;
68mod units_of_measure;
69mod vendor_credits;
70mod vendor_returns;
71mod warehouse;
72mod warranties;
73mod wishlists;
74mod work_orders;
75mod x402_credits;
76mod x402_payment_intents;
77mod zone_shipping_methods;
78
79#[cfg(feature = "vector")]
80mod vector;
81
82pub use a2a::*;
83pub use accounts_payable::*;
84pub use accounts_receivable::*;
85pub use activity_logs::*;
86pub use agent_cards::*;
87pub use agent_identities::*;
88pub use agent_reputation::*;
89pub use agent_validation::*;
90pub use analytics::*;
91pub use backorder::*;
92pub use bom::*;
93pub use carts::*;
94pub use channels::*;
95pub use companies::*;
96pub use cost_accounting::*;
97pub use credit::*;
98pub use currency::*;
99pub use custom_objects::*;
100pub use customers::*;
101pub use edi_documents::*;
102pub use fixed_assets::*;
103pub use fraud::*;
104pub use fulfillment::*;
105pub use general_ledger::*;
106pub use gift_cards::*;
107pub use http_idempotency::*;
108pub use inbound_shipments::*;
109pub use integration_field_mappings::*;
110pub use integration_mappings::*;
111pub use inventory::*;
112pub use invoices::*;
113pub use lots::*;
114pub use loyalty::*;
115pub use orders::*;
116pub use payment_obligations::*;
117pub use payments::*;
118pub use prepayments::*;
119pub use price_levels::*;
120pub use price_schedules::*;
121pub use print_stations::*;
122pub use production_batches::*;
123pub use products::*;
124pub use promotions::*;
125pub use purchase_orders::*;
126pub use purgatory::*;
127pub use quality::*;
128pub use receiving::*;
129pub use returns::*;
130pub use revenue_recognition::*;
131pub use reviews::*;
132pub use rewards::*;
133pub use search_configs::*;
134pub use segments::*;
135pub use serials::*;
136pub use shipments::*;
137pub use shipping_zones::*;
138pub use stock_snapshots::*;
139pub use store_credits::*;
140pub use subscriptions::*;
141pub use supplier_skus::*;
142pub use tax::*;
143pub use topology_snapshots::*;
144pub use transfer_orders::*;
145pub use units_of_measure::*;
146#[cfg(feature = "vector")]
147pub use vector::*;
148pub use vendor_credits::*;
149pub use vendor_returns::*;
150pub use warehouse::*;
151pub use warranties::*;
152pub use wishlists::*;
153pub use work_orders::*;
154pub use x402_credits::*;
155pub use x402_payment_intents::*;
156pub use zone_shipping_methods::*;
157
158use crate::DatabaseConfig;
159use crate::migrations;
160use r2d2::{Pool, PooledConnection};
161use r2d2_sqlite::SqliteConnectionManager;
162use rusqlite::OpenFlags;
163use rust_decimal::Decimal;
164use stateset_core::CommerceError;
165use std::panic::{self, AssertUnwindSafe};
166use std::thread;
167use std::time::Duration;
168
169/// SQLite database connection pool
170#[derive(Debug)]
171pub struct SqliteDatabase {
172    pool: Pool<SqliteConnectionManager>,
173}
174
175/// Best-effort cleanup for `:memory:` databases, which are backed by private
176/// temp files (see the pool construction below for why).
177#[derive(Debug)]
178struct EphemeralDbGuard {
179    path: std::path::PathBuf,
180}
181
182impl Drop for EphemeralDbGuard {
183    fn drop(&mut self) {
184        for suffix in ["", "-wal", "-shm"] {
185            let mut p = self.path.as_os_str().to_owned();
186            p.push(suffix);
187            let _ = std::fs::remove_file(std::path::PathBuf::from(p));
188        }
189    }
190}
191
192/// Database health status returned by [`SqliteDatabase::health_check`].
193#[derive(Debug, Clone, serde::Serialize)]
194pub struct DbHealthStatus {
195    /// Round-trip latency to DB in milliseconds
196    pub latency_ms: u64,
197    /// Total connections in pool
198    pub pool_size: u32,
199    /// Idle connections available
200    pub pool_idle: u32,
201    /// Total database file size in bytes
202    pub db_size_bytes: u64,
203    /// Free pages available for reuse
204    pub freelist_pages: u64,
205}
206
207#[derive(Debug)]
208struct PragmaScope {
209    conn: PooledConnection<SqliteConnectionManager>,
210    previous_timeout: u64,
211    previous_read_uncommitted: Option<bool>,
212}
213
214impl PragmaScope {
215    fn new(
216        conn: PooledConnection<SqliteConnectionManager>,
217        timeout_ms: u64,
218        set_read_uncommitted: bool,
219        fallback_timeout_ms: u64,
220    ) -> Result<Self, rusqlite::Error> {
221        let previous_timeout: u64 = conn
222            .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, u64>(0))
223            .unwrap_or(fallback_timeout_ms);
224
225        conn.execute_batch(&format!("PRAGMA busy_timeout = {timeout_ms}"))?;
226
227        let previous_read_uncommitted = if set_read_uncommitted {
228            let previous: i64 = conn
229                .query_row("PRAGMA read_uncommitted", [], |row| row.get::<_, i64>(0))
230                .unwrap_or(0);
231            let previous_read_uncommitted = previous == 1;
232            conn.execute_batch("PRAGMA read_uncommitted = true")?;
233            Some(previous_read_uncommitted)
234        } else {
235            None
236        };
237
238        Ok(Self { conn, previous_timeout, previous_read_uncommitted })
239    }
240}
241
242impl std::ops::Deref for PragmaScope {
243    type Target = PooledConnection<SqliteConnectionManager>;
244
245    fn deref(&self) -> &Self::Target {
246        &self.conn
247    }
248}
249
250impl std::ops::DerefMut for PragmaScope {
251    fn deref_mut(&mut self) -> &mut Self::Target {
252        &mut self.conn
253    }
254}
255
256impl Drop for PragmaScope {
257    fn drop(&mut self) {
258        let _ =
259            self.conn.execute_batch(&format!("PRAGMA busy_timeout = {}", self.previous_timeout));
260        if let Some(previous_read_uncommitted) = self.previous_read_uncommitted {
261            let _ = self.conn.execute_batch(&format!(
262                "PRAGMA read_uncommitted = {}",
263                if previous_read_uncommitted { "true" } else { "false" }
264            ));
265        }
266    }
267}
268
269impl SqliteDatabase {
270    /// Create a new SQLite database connection
271    pub fn new(config: &DatabaseConfig) -> Result<Self, CommerceError> {
272        use std::sync::atomic::{AtomicU64, Ordering};
273        use std::time::Duration;
274
275        // Counter to generate unique database names for each in-memory instance
276        static MEMORY_DB_COUNTER: AtomicU64 = AtomicU64::new(0);
277
278        // `:memory:` databases are backed by a PRIVATE TEMP FILE, not SQLite
279        // shared-cache. Shared-cache (the only way a connection pool can see
280        // one true in-memory database) uses table-level locks whose semantics
281        // differ from file databases — BEGIN IMMEDIATE does not fully
282        // serialize read-modify-write transactions there, which surfaced as a
283        // rare lost update in the weighted-average-cost concurrency test. A
284        // temp file gets the exact production locking model (WAL, IMMEDIATE
285        // serialization, full pool concurrency); on Linux, tmp is typically
286        // RAM-backed anyway. The files are deleted when the last handle drops.
287        let is_memory = config.url == ":memory:";
288        let mut ephemeral = None;
289        let (manager, max_connections) = if is_memory {
290            let db_id = MEMORY_DB_COUNTER.fetch_add(1, Ordering::SeqCst);
291            let path = std::env::temp_dir()
292                .join(format!("stateset_memdb_{}_{db_id}.db", std::process::id()));
293            ephemeral = Some(std::sync::Arc::new(EphemeralDbGuard { path: path.clone() }));
294            let manager = SqliteConnectionManager::file(&path).with_flags(
295                OpenFlags::SQLITE_OPEN_READ_WRITE
296                    | OpenFlags::SQLITE_OPEN_CREATE
297                    | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
298            );
299            (manager, config.max_connections.max(1))
300        } else {
301            let manager = SqliteConnectionManager::file(&config.url).with_flags(
302                OpenFlags::SQLITE_OPEN_READ_WRITE
303                    | OpenFlags::SQLITE_OPEN_CREATE
304                    | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
305            );
306            (manager, config.max_connections)
307        };
308        let manager = manager.with_init(move |conn| {
309            // Keep the ephemeral-file guard alive as long as the POOL does
310            // (the init closure lives inside the connection manager). Tying
311            // it to `SqliteDatabase` instead deleted the temp file while
312            // accessor repos still held pool clones — lazily opened
313            // connections then recreated an EMPTY database ("no such table").
314            let _keep_ephemeral_alive = &ephemeral;
315            // Use longer busy_timeout for high concurrency scenarios
316            conn.execute_batch(&format!(
317                "PRAGMA foreign_keys = ON; PRAGMA busy_timeout = {};",
318                crate::DEFAULT_TRANSACTION_TIMEOUT_MS
319            ))?;
320            conn.execute_batch("PRAGMA journal_mode = WAL;")?;
321            // Performance tuning: reduce fsync overhead and increase cache
322            conn.execute_batch(
323                "PRAGMA synchronous = NORMAL;\
324                 PRAGMA cache_size = -16000;\
325                 PRAGMA temp_store = MEMORY;\
326                 PRAGMA mmap_size = 268435456;\
327                 PRAGMA wal_autocheckpoint = 10000;",
328            )?;
329            // Exact-decimal money aggregate (decimal_sum) for analytics totals;
330            // see money_agg for why SQL's float SUM() is unsafe on TEXT money.
331            money_agg::register(conn)?;
332            Ok(())
333        });
334
335        let pool = Pool::builder()
336            .max_size(max_connections)
337            .connection_timeout(Duration::from_secs(30))
338            .build(manager)
339            .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
340
341        // Get connection for setup
342        let mut conn = pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
343
344        // Run migrations
345        migrations::run_migrations(&mut conn)
346            .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
347
348        Ok(Self { pool })
349    }
350
351    /// Create an in-memory database (useful for testing)
352    pub fn in_memory() -> Result<Self, CommerceError> {
353        Self::new(&DatabaseConfig::in_memory())
354    }
355
356    /// Get a connection from the pool
357    pub fn conn(&self) -> Result<PooledConnection<SqliteConnectionManager>, CommerceError> {
358        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
359    }
360
361    /// Run a health check: verify DB connectivity, return pool and storage stats.
362    pub fn health_check(&self) -> Result<DbHealthStatus, CommerceError> {
363        let start = std::time::Instant::now();
364        let conn = self.conn()?;
365        // Verify DB is responsive with a trivial query
366        let _: i64 = conn
367            .query_row("SELECT 1", [], |row| row.get(0))
368            .map_err(|e| CommerceError::DatabaseError(format!("Health check query failed: {e}")))?;
369
370        // Get storage stats (page_count * page_size = total DB size)
371        let page_count: i64 =
372            conn.query_row("PRAGMA page_count", [], |row| row.get(0)).unwrap_or(0);
373        let page_size: i64 =
374            conn.query_row("PRAGMA page_size", [], |row| row.get(0)).unwrap_or(4096);
375        let freelist_count: i64 =
376            conn.query_row("PRAGMA freelist_count", [], |row| row.get(0)).unwrap_or(0);
377
378        let pool_state = self.pool.state();
379
380        Ok(DbHealthStatus {
381            latency_ms: start.elapsed().as_millis() as u64,
382            pool_size: pool_state.connections,
383            pool_idle: pool_state.idle_connections,
384            db_size_bytes: (page_count * page_size) as u64,
385            freelist_pages: freelist_count as u64,
386        })
387    }
388
389    /// Graceful shutdown: checkpoint WAL and optimize before closing.
390    ///
391    /// Call this before dropping the database to ensure all WAL data is
392    /// flushed to the main database file. Safe to call multiple times.
393    pub fn shutdown(&self) -> Result<(), CommerceError> {
394        let conn = self.conn()?;
395        // Checkpoint WAL to main database file
396        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")
397            .map_err(|e| CommerceError::DatabaseError(format!("WAL checkpoint failed: {e}")))?;
398        // Run optimize to update query planner statistics
399        conn.execute_batch("PRAGMA optimize")
400            .map_err(|e| CommerceError::DatabaseError(format!("PRAGMA optimize failed: {e}")))?;
401        tracing::info!("Database shutdown: WAL checkpointed and optimized");
402        Ok(())
403    }
404
405    /// Get order repository
406    #[must_use]
407    pub fn orders(&self) -> SqliteOrderRepository {
408        SqliteOrderRepository::new(self.pool.clone())
409    }
410
411    /// Get inventory repository
412    #[must_use]
413    pub fn inventory(&self) -> SqliteInventoryRepository {
414        SqliteInventoryRepository::new(self.pool.clone())
415    }
416
417    /// Get customer repository
418    #[must_use]
419    pub fn customers(&self) -> SqliteCustomerRepository {
420        SqliteCustomerRepository::new(self.pool.clone())
421    }
422
423    /// Get product repository
424    #[must_use]
425    pub fn products(&self) -> SqliteProductRepository {
426        SqliteProductRepository::new(self.pool.clone())
427    }
428
429    /// Get custom objects repository (custom states / metaobjects)
430    #[must_use]
431    pub fn custom_objects(&self) -> SqliteCustomObjectRepository {
432        SqliteCustomObjectRepository::new(self.pool.clone())
433    }
434
435    /// Get return repository
436    #[must_use]
437    pub fn returns(&self) -> SqliteReturnRepository {
438        SqliteReturnRepository::new(self.pool.clone())
439    }
440
441    /// Get BOM (Bill of Materials) repository
442    #[must_use]
443    pub fn bom(&self) -> SqliteBomRepository {
444        SqliteBomRepository::new(self.pool.clone())
445    }
446
447    /// Get work order repository
448    #[must_use]
449    pub fn work_orders(&self) -> SqliteWorkOrderRepository {
450        SqliteWorkOrderRepository::new(self.pool.clone())
451    }
452
453    /// Get shipment repository
454    #[must_use]
455    pub fn shipments(&self) -> SqliteShipmentRepository {
456        SqliteShipmentRepository::new(self.pool.clone())
457    }
458
459    /// Get payment repository
460    #[must_use]
461    pub fn payments(&self) -> SqlitePaymentRepository {
462        SqlitePaymentRepository::new(self.pool.clone())
463    }
464
465    /// Get warranty repository
466    #[must_use]
467    pub fn warranties(&self) -> SqliteWarrantyRepository {
468        SqliteWarrantyRepository::new(self.pool.clone())
469    }
470
471    /// Get purchase order repository
472    #[must_use]
473    pub fn purchase_orders(&self) -> SqlitePurchaseOrderRepository {
474        SqlitePurchaseOrderRepository::new(self.pool.clone())
475    }
476
477    /// Get invoice repository
478    #[must_use]
479    pub fn invoices(&self) -> SqliteInvoiceRepository {
480        SqliteInvoiceRepository::new(self.pool.clone())
481    }
482
483    /// Get cart repository
484    #[must_use]
485    pub fn carts(&self) -> SqliteCartRepository {
486        SqliteCartRepository::new(self.pool.clone())
487    }
488
489    /// Get analytics repository
490    #[must_use]
491    pub fn analytics(&self) -> SqliteAnalyticsRepository {
492        SqliteAnalyticsRepository::new(self.pool.clone())
493    }
494
495    /// Get currency repository
496    #[must_use]
497    pub fn currency(&self) -> SqliteCurrencyRepository {
498        SqliteCurrencyRepository::new(self.pool.clone())
499    }
500
501    /// Get tax repository
502    #[must_use]
503    pub fn tax(&self) -> SqliteTaxRepository {
504        SqliteTaxRepository::new(self.pool.clone())
505    }
506
507    /// Get promotions repository
508    #[must_use]
509    pub fn promotions(&self) -> SqlitePromotionRepository {
510        SqlitePromotionRepository::new(self.pool.clone())
511    }
512
513    /// Get subscriptions repository
514    #[must_use]
515    pub fn subscriptions(&self) -> SqliteSubscriptionRepository {
516        SqliteSubscriptionRepository::new(self.pool.clone())
517    }
518
519    /// Get quality repository
520    #[must_use]
521    pub fn quality(&self) -> SqliteQualityRepository {
522        SqliteQualityRepository::new(self.pool.clone())
523    }
524
525    /// Get lots repository
526    #[must_use]
527    pub fn lots(&self) -> SqliteLotRepository {
528        SqliteLotRepository::new(self.pool.clone())
529    }
530
531    /// Get serials repository
532    #[must_use]
533    pub fn serials(&self) -> SqliteSerialRepository {
534        SqliteSerialRepository::new(self.pool.clone())
535    }
536
537    /// Get warehouse repository
538    #[must_use]
539    pub fn warehouse(&self) -> SqliteWarehouseRepository {
540        SqliteWarehouseRepository::new(self.pool.clone())
541    }
542
543    /// Get receiving repository
544    #[must_use]
545    pub fn receiving(&self) -> SqliteReceivingRepository {
546        SqliteReceivingRepository::new(self.pool.clone())
547    }
548
549    /// Get fulfillment repository
550    #[must_use]
551    pub fn fulfillment(&self) -> SqliteFulfillmentRepository {
552        SqliteFulfillmentRepository::new(self.pool.clone())
553    }
554
555    /// Get accounts payable repository
556    #[must_use]
557    pub fn accounts_payable(&self) -> SqliteAccountsPayableRepository {
558        SqliteAccountsPayableRepository::new(self.pool.clone())
559    }
560
561    /// Get cost accounting repository
562    #[must_use]
563    pub fn cost_accounting(&self) -> SqliteCostAccountingRepository {
564        SqliteCostAccountingRepository::new(self.pool.clone())
565    }
566
567    /// Get credit repository
568    #[must_use]
569    pub fn credit(&self) -> SqliteCreditRepository {
570        SqliteCreditRepository::new(self.pool.clone())
571    }
572
573    /// Get backorder repository
574    #[must_use]
575    pub fn backorder(&self) -> SqliteBackorderRepository {
576        SqliteBackorderRepository::new(self.pool.clone())
577    }
578
579    /// Get accounts receivable repository
580    #[must_use]
581    pub fn accounts_receivable(&self) -> SqliteAccountsReceivableRepository {
582        SqliteAccountsReceivableRepository::new(self.pool.clone())
583    }
584
585    /// Get general ledger repository
586    #[must_use]
587    pub fn general_ledger(&self) -> SqliteGeneralLedgerRepository {
588        SqliteGeneralLedgerRepository::new(self.pool.clone())
589    }
590
591    /// Get vector search repository (requires `vector` feature)
592    #[cfg(feature = "vector")]
593    pub fn vector(&self) -> SqliteVectorRepository {
594        SqliteVectorRepository::new(self.pool.clone())
595    }
596
597    /// Get x402 payment intent repository
598    #[must_use]
599    pub fn x402_payment_intents(&self) -> SqliteX402PaymentIntentRepository {
600        SqliteX402PaymentIntentRepository::new(self.pool.clone())
601    }
602
603    /// Get x402 credit ledger repository
604    #[must_use]
605    pub fn x402_credits(&self) -> SqliteX402CreditRepository {
606        SqliteX402CreditRepository::new(self.pool.clone())
607    }
608
609    /// Get A2A quote/purchase repository
610    #[must_use]
611    pub fn a2a_quotes(&self) -> SqliteA2ARepository {
612        SqliteA2ARepository::new(self.pool.clone())
613    }
614
615    /// Get A2A quote/purchase repository
616    #[must_use]
617    pub fn a2a_purchases(&self) -> SqliteA2ARepository {
618        SqliteA2ARepository::new(self.pool.clone())
619    }
620
621    /// Get agent card repository
622    #[must_use]
623    pub fn agent_cards(&self) -> SqliteAgentCardRepository {
624        SqliteAgentCardRepository::new(self.pool.clone())
625    }
626
627    /// Get agent identity repository (ERC-8004)
628    #[must_use]
629    pub fn agent_identities(&self) -> SqliteAgentIdentityRepository {
630        SqliteAgentIdentityRepository::new(self.pool.clone())
631    }
632
633    /// Get agent reputation repository (ERC-8004)
634    #[must_use]
635    pub fn agent_reputation(&self) -> SqliteAgentReputationRepository {
636        SqliteAgentReputationRepository::new(self.pool.clone())
637    }
638
639    /// Get agent validation repository (ERC-8004)
640    #[must_use]
641    pub fn agent_validation(&self) -> SqliteAgentValidationRepository {
642        SqliteAgentValidationRepository::new(self.pool.clone())
643    }
644
645    /// Get gift card repository
646    #[must_use]
647    pub fn gift_cards(&self) -> SqliteGiftCardRepository {
648        SqliteGiftCardRepository::new(self.pool.clone())
649    }
650
651    /// Get store credit repository
652    #[must_use]
653    pub fn store_credits(&self) -> SqliteStoreCreditRepository {
654        SqliteStoreCreditRepository::new(self.pool.clone())
655    }
656
657    /// Get customer segment repository
658    #[must_use]
659    pub fn segments(&self) -> SqliteSegmentRepository {
660        SqliteSegmentRepository::new(self.pool.clone())
661    }
662
663    /// Get shipping zone repository
664    #[must_use]
665    pub fn shipping_zones(&self) -> SqliteShippingZoneRepository {
666        SqliteShippingZoneRepository::new(self.pool.clone())
667    }
668
669    /// Get channel repository
670    #[must_use]
671    pub fn channels(&self) -> SqliteChannelRepository {
672        SqliteChannelRepository::new(self.pool.clone())
673    }
674
675    /// Get company (B2B account) repository
676    #[must_use]
677    pub fn companies(&self) -> SqliteCompanyRepository {
678        SqliteCompanyRepository::new(self.pool.clone())
679    }
680
681    /// Get transfer order repository
682    #[must_use]
683    pub fn transfer_orders(&self) -> SqliteTransferOrderRepository {
684        SqliteTransferOrderRepository::new(self.pool.clone())
685    }
686
687    /// Get units-of-measure repository
688    #[must_use]
689    pub fn units_of_measure(&self) -> SqliteUnitOfMeasureRepository {
690        SqliteUnitOfMeasureRepository::new(self.pool.clone())
691    }
692
693    /// Get production batch repository
694    #[must_use]
695    pub fn production_batches(&self) -> SqliteProductionBatchRepository {
696        SqliteProductionBatchRepository::new(self.pool.clone())
697    }
698
699    /// Get supplier SKU repository
700    #[must_use]
701    pub fn supplier_skus(&self) -> SqliteSupplierSkuRepository {
702        SqliteSupplierSkuRepository::new(self.pool.clone())
703    }
704
705    /// Get fixed asset repository
706    #[must_use]
707    pub fn fixed_assets(&self) -> SqliteFixedAssetRepository {
708        SqliteFixedAssetRepository::new(self.pool.clone())
709    }
710
711    /// Get revenue recognition repository
712    #[must_use]
713    pub fn revenue_recognition(&self) -> SqliteRevenueRecognitionRepository {
714        SqliteRevenueRecognitionRepository::new(self.pool.clone())
715    }
716
717    /// Get vendor return repository
718    #[must_use]
719    pub fn vendor_returns(&self) -> SqliteVendorReturnRepository {
720        SqliteVendorReturnRepository::new(self.pool.clone())
721    }
722
723    /// Get vendor credit repository
724    #[must_use]
725    pub fn vendor_credits(&self) -> SqliteVendorCreditRepository {
726        SqliteVendorCreditRepository::new(self.pool.clone())
727    }
728
729    /// Get payment obligation repository
730    #[must_use]
731    pub fn payment_obligations(&self) -> SqlitePaymentObligationRepository {
732        SqlitePaymentObligationRepository::new(self.pool.clone())
733    }
734
735    /// Get price level repository
736    #[must_use]
737    pub fn price_levels(&self) -> SqlitePriceLevelRepository {
738        SqlitePriceLevelRepository::new(self.pool.clone())
739    }
740
741    /// Get prepayment repository
742    #[must_use]
743    pub fn prepayments(&self) -> SqlitePrepaymentRepository {
744        SqlitePrepaymentRepository::new(self.pool.clone())
745    }
746
747    /// Get price schedule repository
748    #[must_use]
749    pub fn price_schedules(&self) -> SqlitePriceScheduleRepository {
750        SqlitePriceScheduleRepository::new(self.pool.clone())
751    }
752
753    /// Get the durable HTTP idempotency repository
754    #[must_use]
755    pub fn http_idempotency(&self) -> SqliteHttpIdempotencyRepository {
756        SqliteHttpIdempotencyRepository::new(self.pool.clone())
757    }
758
759    /// Get activity log repository
760    #[must_use]
761    pub fn activity_logs(&self) -> SqliteActivityLogRepository {
762        SqliteActivityLogRepository::new(self.pool.clone())
763    }
764
765    /// Get integration mapping repository
766    #[must_use]
767    pub fn integration_mappings(&self) -> SqliteIntegrationMappingRepository {
768        SqliteIntegrationMappingRepository::new(self.pool.clone())
769    }
770
771    /// Get inbound shipment repository
772    #[must_use]
773    pub fn inbound_shipments(&self) -> SqliteInboundShipmentRepository {
774        SqliteInboundShipmentRepository::new(self.pool.clone())
775    }
776
777    /// Get purgatory (order ingestion staging) repository
778    #[must_use]
779    pub fn purgatory(&self) -> SqlitePurgatoryRepository {
780        SqlitePurgatoryRepository::new(self.pool.clone())
781    }
782
783    /// Get print station repository
784    #[must_use]
785    pub fn print_stations(&self) -> SqlitePrintStationRepository {
786        SqlitePrintStationRepository::new(self.pool.clone())
787    }
788
789    /// Get EDI document repository
790    #[must_use]
791    pub fn edi_documents(&self) -> SqliteEdiDocumentRepository {
792        SqliteEdiDocumentRepository::new(self.pool.clone())
793    }
794
795    /// Get integration field-mapping repository
796    #[must_use]
797    pub fn integration_field_mappings(&self) -> SqliteIntegrationFieldMappingRepository {
798        SqliteIntegrationFieldMappingRepository::new(self.pool.clone())
799    }
800
801    /// Get topology snapshot repository
802    #[must_use]
803    pub fn topology_snapshots(&self) -> SqliteTopologySnapshotRepository {
804        SqliteTopologySnapshotRepository::new(self.pool.clone())
805    }
806
807    /// Get stock snapshot repository
808    #[must_use]
809    pub fn stock_snapshots(&self) -> SqliteStockSnapshotRepository {
810        SqliteStockSnapshotRepository::new(self.pool.clone())
811    }
812
813    /// Get zone shipping method repository
814    #[must_use]
815    pub fn zone_shipping_methods(&self) -> SqliteZoneShippingMethodRepository {
816        SqliteZoneShippingMethodRepository::new(self.pool.clone())
817    }
818
819    /// Get product review repository
820    #[must_use]
821    pub fn reviews(&self) -> SqliteReviewRepository {
822        SqliteReviewRepository::new(self.pool.clone())
823    }
824
825    /// Get wishlist repository
826    #[must_use]
827    pub fn wishlists(&self) -> SqliteWishlistRepository {
828        SqliteWishlistRepository::new(self.pool.clone())
829    }
830
831    /// Get loyalty program repository
832    #[must_use]
833    pub fn loyalty_programs(&self) -> SqliteLoyaltyProgramRepository {
834        SqliteLoyaltyProgramRepository::new(self.pool.clone())
835    }
836
837    /// Get reward catalog repository
838    #[must_use]
839    pub fn rewards(&self) -> SqliteRewardRepository {
840        SqliteRewardRepository::new(self.pool.clone())
841    }
842
843    /// Get fraud detection repository
844    #[must_use]
845    pub fn fraud(&self) -> SqliteFraudRepository {
846        SqliteFraudRepository::new(self.pool.clone())
847    }
848
849    /// Get search configuration repository
850    #[must_use]
851    pub fn search_configs(&self) -> SqliteSearchConfigRepository {
852        SqliteSearchConfigRepository::new(self.pool.clone())
853    }
854
855    /// Get underlying pool (for advanced use)
856    #[must_use]
857    pub const fn pool(&self) -> &Pool<SqliteConnectionManager> {
858        &self.pool
859    }
860}
861
862/// Helper function to convert rusqlite errors to `CommerceError`
863pub(crate) fn map_db_error(e: rusqlite::Error) -> CommerceError {
864    match e {
865        rusqlite::Error::QueryReturnedNoRows => CommerceError::NotFound,
866        rusqlite::Error::ToSqlConversionFailure(boxed) => {
867            // Extract CommerceError if it was wrapped for transaction propagation
868            match boxed.downcast::<CommerceError>() {
869                Ok(commerce_error) => *commerce_error,
870                Err(other) => CommerceError::DatabaseError(other.to_string()),
871            }
872        }
873        rusqlite::Error::SqliteFailure(err, _) => {
874            match err.code {
875                // SQLITE_CONSTRAINT (19): unique/fk/check violation
876                rusqlite::ErrorCode::ConstraintViolation => {
877                    let msg = e.to_string();
878                    if msg.contains("UNIQUE") {
879                        CommerceError::Conflict(msg)
880                    } else {
881                        CommerceError::ValidationError(msg)
882                    }
883                }
884                _ => {
885                    // SQLITE_FULL = 13
886                    if err.extended_code == rusqlite::ffi::SQLITE_FULL {
887                        CommerceError::Internal("Storage full: database disk is full".into())
888                    } else {
889                        CommerceError::DatabaseError(e.to_string())
890                    }
891                }
892            }
893        }
894        _ => CommerceError::DatabaseError(e.to_string()),
895    }
896}
897
898// Re-export parse helpers for use in submodules
899pub(crate) use parse_helpers::{
900    parse_date_row,
901    parse_datetime,
902    parse_datetime_opt,
903    parse_datetime_opt_row,
904    parse_datetime_row,
905    parse_decimal as parse_decimal_strict,
906    parse_decimal_opt,
907    parse_decimal_opt_row,
908    parse_decimal_row,
909    parse_enum,
910    parse_enum_row,
911    parse_json_opt_row,
912    parse_json_row,
913    // Non-row variants for use outside rusqlite closures
914    parse_uuid,
915    parse_uuid_opt,
916    parse_uuid_opt_row,
917    parse_uuid_row,
918};
919
920/// Escape LIKE wildcard characters (%, _, \) in a search string.
921///
922/// Prevents user-supplied search terms from acting as LIKE metacharacters.
923/// Use with `LIKE ? ESCAPE '\'` in the SQL query.
924#[must_use]
925pub(crate) fn escape_like(input: &str) -> String {
926    let mut out = String::with_capacity(input.len());
927    for ch in input.chars() {
928        match ch {
929            '%' | '_' | '\\' => {
930                out.push('\\');
931                out.push(ch);
932            }
933            _ => out.push(ch),
934        }
935    }
936    out
937}
938
939// ============================================================================
940// Batch Operation Helpers
941// ============================================================================
942
943/// Build SQL IN clause with placeholders for the given count
944/// Example: `build_in_clause(3)` returns "?, ?, ?"
945pub(crate) fn build_in_clause(count: usize) -> String {
946    if count == 0 {
947        return "NULL".to_string();
948    }
949
950    std::iter::repeat_n("?", count).collect::<Vec<_>>().join(", ")
951}
952
953/// Default page size applied when a list filter does not specify a limit.
954pub(crate) const DEFAULT_LIST_LIMIT: u32 = 500;
955
956/// Hard server-side ceiling on requested page sizes.
957pub(crate) const MAX_LIST_LIMIT: u32 = 1000;
958
959/// Clamp a requested page size to the server-side pagination policy:
960/// `None` becomes [`DEFAULT_LIST_LIMIT`], and anything above
961/// [`MAX_LIST_LIMIT`] is capped to it.
962pub(crate) const fn effective_limit(limit: Option<u32>) -> u32 {
963    match limit {
964        Some(limit) if limit > MAX_LIST_LIMIT => MAX_LIST_LIMIT,
965        Some(limit) => limit,
966        None => DEFAULT_LIST_LIMIT,
967    }
968}
969
970/// Append a `LIMIT`/`OFFSET` pagination clause to a query string.
971///
972/// A `LIMIT` is always emitted: when the filter supplies no limit the
973/// server-side default ([`DEFAULT_LIST_LIMIT`]) applies, and requested limits
974/// are capped at [`MAX_LIST_LIMIT`] — unbounded scans are never produced.
975pub(crate) fn append_limit_offset<O: std::fmt::Display>(
976    sql: &mut String,
977    limit: Option<u32>,
978    offset: Option<O>,
979) {
980    let limit = effective_limit(limit);
981    match offset {
982        Some(offset) => sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}")),
983        None => sql.push_str(&format!(" LIMIT {limit}")),
984    }
985}
986
987/// Check if SQLite JSON1 functions are available.
988pub(crate) fn json1_available(conn: &rusqlite::Connection) -> bool {
989    conn.query_row("SELECT json_valid('[]')", [], |row| row.get::<_, i32>(0))
990        .map(|value| value == 1)
991        .unwrap_or(false)
992}
993
994/// Sum a single decimal column from a query using exact Decimal parsing.
995pub(crate) fn sum_decimal_query(
996    conn: &rusqlite::Connection,
997    sql: &str,
998    params: &[&dyn rusqlite::ToSql],
999    entity: &str,
1000    field: &str,
1001) -> stateset_core::Result<Decimal> {
1002    let mut stmt = conn.prepare(sql).map_err(map_db_error)?;
1003    let mut rows = stmt.query(params).map_err(map_db_error)?;
1004    let mut total = Decimal::ZERO;
1005
1006    while let Some(row) = rows.next().map_err(map_db_error)? {
1007        let raw: Option<String> = row.get(0).map_err(map_db_error)?;
1008        if let Some(raw) = raw {
1009            if !raw.is_empty() {
1010                total += parse_decimal_strict(&raw, entity, field)?;
1011            }
1012        }
1013    }
1014
1015    Ok(total)
1016}
1017
1018/// Convert a slice of UUIDs to boxed parameter vector for rusqlite
1019pub(crate) fn uuid_params(ids: &[uuid::Uuid]) -> Vec<Box<dyn rusqlite::ToSql>> {
1020    ids.iter().map(|id| Box::new(id.to_string()) as Box<dyn rusqlite::ToSql>).collect()
1021}
1022
1023/// Convert boxed params to references for rusqlite execution
1024pub(crate) fn params_refs(params: &[Box<dyn rusqlite::ToSql>]) -> Vec<&dyn rusqlite::ToSql> {
1025    params.iter().map(std::convert::AsRef::as_ref).collect()
1026}
1027
1028/// Convert a slice of i64 IDs to boxed parameter vector for rusqlite
1029pub(crate) fn i64_params(ids: &[i64]) -> Vec<Box<dyn rusqlite::ToSql>> {
1030    ids.iter().map(|id| Box::new(*id) as Box<dyn rusqlite::ToSql>).collect()
1031}
1032
1033/// Convert a slice of strings to boxed parameter vector for rusqlite
1034pub(crate) fn string_params(strings: &[String]) -> Vec<Box<dyn rusqlite::ToSql>> {
1035    strings.iter().map(|s| Box::new(s.clone()) as Box<dyn rusqlite::ToSql>).collect()
1036}
1037
1038// ============================================================================
1039// Retry Helpers for SQLite Concurrency
1040// ============================================================================
1041
1042/// Maximum number of retries for transient database errors
1043const MAX_RETRIES: u32 = 50;
1044
1045/// Initial backoff delay in milliseconds
1046const INITIAL_BACKOFF_MS: u64 = 1;
1047
1048/// Maximum backoff delay in milliseconds
1049const MAX_BACKOFF_MS: u64 = 200;
1050
1051/// Check if a rusqlite error is a transient lock error that can be retried
1052pub(crate) fn is_retryable_error(e: &rusqlite::Error) -> bool {
1053    match e {
1054        rusqlite::Error::SqliteFailure(ffi_err, msg) => {
1055            matches!(
1056                ffi_err.code,
1057                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
1058            ) || msg.as_ref().is_some_and(|m| {
1059                m.contains("database is locked") || m.contains("database table is locked")
1060            })
1061        }
1062        _ => false,
1063    }
1064}
1065
1066/// Execute a database operation with retry logic for transient lock errors.
1067/// Uses exponential backoff with jitter to avoid thundering herd.
1068/// Per-retry backoff jitter in milliseconds (0..50), from a thread-local
1069/// xorshift PRNG. Each thread seeds from `RandomState` so concurrent
1070/// contenders draw decorrelated sequences instead of backing off in lockstep
1071/// and re-colliding every round.
1072pub(crate) fn retry_jitter(retries: u32) -> u64 {
1073    use std::cell::Cell;
1074    use std::hash::{BuildHasher, Hasher, RandomState};
1075
1076    thread_local! {
1077        static SEED: Cell<u64> = Cell::new({
1078            // RandomState carries fresh per-instance entropy, giving every
1079            // thread its own seed. Avoid 0, the xorshift fixed point.
1080            let mut hasher = RandomState::new().build_hasher();
1081            hasher.write_u64(0xa076_1d64_78bd_642f);
1082            hasher.finish().max(1)
1083        });
1084    }
1085
1086    SEED.with(|seed| {
1087        let mut s = seed.get().wrapping_add(u64::from(retries));
1088        s ^= s << 13;
1089        s ^= s >> 7;
1090        s ^= s << 17;
1091        seed.set(s);
1092        s % 50
1093    })
1094}
1095
1096pub(crate) fn with_retry<T, F>(mut f: F, max_retries: u32) -> Result<T, rusqlite::Error>
1097where
1098    F: FnMut() -> Result<T, rusqlite::Error>,
1099{
1100    let mut retries = 0;
1101    let mut backoff_ms = INITIAL_BACKOFF_MS;
1102
1103    loop {
1104        match f() {
1105            Ok(result) => return Ok(result),
1106            Err(e) if is_retryable_error(&e) && retries < max_retries => {
1107                retries += 1;
1108                let jitter = retry_jitter(retries);
1109                let delay = backoff_ms.min(MAX_BACKOFF_MS) + jitter;
1110                thread::sleep(Duration::from_millis(delay));
1111                // Exponential backoff with cap
1112                backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
1113            }
1114            Err(e) => return Err(e),
1115        }
1116    }
1117}
1118
1119/// Threshold above which transactions are logged as slow (milliseconds).
1120const SLOW_QUERY_THRESHOLD_MS: u128 = 500;
1121
1122/// Begin an IMMEDIATE-mode write transaction on an existing pooled connection.
1123///
1124/// Thin wrapper used by store write paths whose control flow does not fit the
1125/// closure shape of [`with_immediate_transaction`]. IMMEDIATE mode acquires the
1126/// write lock up front, avoiding `SQLITE_BUSY` lock-upgrade failures and lost
1127/// updates that DEFERRED transactions risk under write concurrency.
1128pub(crate) fn begin_immediate(
1129    conn: &mut rusqlite::Connection,
1130) -> rusqlite::Result<rusqlite::Transaction<'_>> {
1131    conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1132}
1133
1134/// Execute a transactional database operation with IMMEDIATE transaction mode
1135/// and retry logic. IMMEDIATE transactions acquire write locks immediately,
1136/// avoiding deadlocks caused by lock upgrade failures in DEFERRED mode.
1137pub(crate) fn with_immediate_transaction<T, F>(
1138    pool: &Pool<SqliteConnectionManager>,
1139    f: F,
1140) -> stateset_core::Result<T>
1141where
1142    F: Fn(&rusqlite::Transaction<'_>) -> Result<T, rusqlite::Error>,
1143{
1144    let start = std::time::Instant::now();
1145    let result = with_retry(
1146        || {
1147            let mut conn = pool.get().map_err(|e| {
1148                rusqlite::Error::SqliteFailure(
1149                    rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
1150                    Some(e.to_string()),
1151                )
1152            })?;
1153
1154            // Defer FK checks until commit for better batch performance
1155            conn.execute_batch("PRAGMA defer_foreign_keys = ON")?;
1156
1157            // Use IMMEDIATE transaction to acquire write lock immediately
1158            let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1159
1160            let result = f(&tx)?;
1161            tx.commit()?;
1162            Ok(result)
1163        },
1164        MAX_RETRIES,
1165    )
1166    .map_err(map_db_error);
1167
1168    let elapsed = start.elapsed();
1169    if elapsed.as_millis() > SLOW_QUERY_THRESHOLD_MS {
1170        tracing::warn!(
1171            duration_ms = elapsed.as_millis() as u64,
1172            "Slow transaction detected (>{SLOW_QUERY_THRESHOLD_MS}ms)"
1173        );
1174    }
1175
1176    result
1177}
1178
1179// Transaction support implementation
1180use crate::DatabaseExt;
1181
1182impl DatabaseExt for SqliteDatabase {
1183    fn with_transaction<F, T>(&self, f: F) -> stateset_core::Result<T>
1184    where
1185        F: FnMut(&rusqlite::Connection) -> std::result::Result<T, rusqlite::Error>,
1186    {
1187        self.with_transaction_opts(crate::TransactionOptions::new(), f)
1188    }
1189
1190    fn with_transaction_opts<F, T>(
1191        &self,
1192        opts: crate::TransactionOptions,
1193        mut f: F,
1194    ) -> stateset_core::Result<T>
1195    where
1196        F: FnMut(&rusqlite::Connection) -> std::result::Result<T, rusqlite::Error>,
1197    {
1198        let retries = if opts.retry_on_conflict { opts.max_retries } else { 0 };
1199        let timeout_ms = opts.timeout_ms.unwrap_or(crate::DEFAULT_TRANSACTION_TIMEOUT_MS);
1200        let set_read_uncommitted =
1201            matches!(opts.isolation, crate::TransactionIsolation::ReadUncommitted);
1202        let fallback_timeout_ms = timeout_ms;
1203
1204        crate::sqlite::with_retry(
1205            || {
1206                let conn = self.pool.get().map_err(|e| {
1207                    rusqlite::Error::SqliteFailure(
1208                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
1209                        Some(e.to_string()),
1210                    )
1211                })?;
1212                let mut conn =
1213                    PragmaScope::new(conn, timeout_ms, set_read_uncommitted, fallback_timeout_ms)?;
1214
1215                {
1216                    let tx =
1217                        conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1218                    let result = panic::catch_unwind(AssertUnwindSafe(|| f(&tx)));
1219
1220                    match result {
1221                        Ok(result) => {
1222                            let result = result?;
1223                            tx.commit()?;
1224                            Ok(result)
1225                        }
1226                        Err(panic_payload) => {
1227                            panic::resume_unwind(panic_payload);
1228                        }
1229                    }
1230                }
1231            },
1232            retries,
1233        )
1234        .map_err(map_db_error)
1235    }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241    use std::cell::Cell;
1242
1243    #[test]
1244    fn append_limit_offset_covers_all_cases() {
1245        let case = |limit: Option<u32>, offset: Option<u32>| {
1246            let mut sql = "SELECT * FROM t".to_string();
1247            append_limit_offset(&mut sql, limit, offset);
1248            sql
1249        };
1250        assert_eq!(case(Some(10), Some(5)), "SELECT * FROM t LIMIT 10 OFFSET 5");
1251        assert_eq!(case(Some(10), None), "SELECT * FROM t LIMIT 10");
1252        // No limit → server-side default applies (never an unbounded scan).
1253        assert_eq!(case(None, None), "SELECT * FROM t LIMIT 500");
1254        // A bare `OFFSET` is invalid on SQLite, so a LIMIT (the default) must
1255        // always precede it.
1256        assert_eq!(case(None, Some(5)), "SELECT * FROM t LIMIT 500 OFFSET 5");
1257        // Requested limits are capped at the server-side maximum.
1258        assert_eq!(case(Some(5000), None), "SELECT * FROM t LIMIT 1000");
1259    }
1260
1261    fn retryable_error() -> rusqlite::Error {
1262        rusqlite::Error::SqliteFailure(
1263            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
1264            Some("database is locked".to_string()),
1265        )
1266    }
1267
1268    #[test]
1269    fn build_in_clause_empty_is_null() {
1270        assert_eq!(build_in_clause(0), "NULL".to_string());
1271    }
1272
1273    #[test]
1274    fn build_in_clause_uses_question_mark_placeholders() {
1275        assert_eq!(build_in_clause(3), "?, ?, ?".to_string());
1276    }
1277
1278    #[test]
1279    fn with_transaction_restores_read_uncommitted_pragma() {
1280        let db = SqliteDatabase::new(&DatabaseConfig {
1281            url: ":memory:".to_string(),
1282            max_connections: 1,
1283        })
1284        .expect("db should initialize");
1285
1286        {
1287            let conn = db.conn().expect("connection should open");
1288            conn.execute_batch("PRAGMA read_uncommitted = true")
1289                .expect("read_uncommitted pragma should be set");
1290            let before: i64 = conn
1291                .query_row("PRAGMA read_uncommitted", [], |row| row.get::<_, i64>(0))
1292                .expect("read_uncommitted pragma should be readable");
1293            assert_eq!(before, 1);
1294        }
1295
1296        db.with_transaction_opts(
1297            crate::TransactionOptions::new()
1298                .isolation(crate::TransactionIsolation::ReadUncommitted),
1299            |conn| {
1300                conn.execute_batch("PRAGMA read_uncommitted = true")?;
1301                Ok(())
1302            },
1303        )
1304        .expect("transaction should succeed");
1305
1306        let conn = db.conn().expect("connection should reopen");
1307        let after: i64 = conn
1308            .query_row("PRAGMA read_uncommitted", [], |row| row.get::<_, i64>(0))
1309            .expect("read_uncommitted pragma should be readable");
1310        assert_eq!(after, 1);
1311    }
1312
1313    #[test]
1314    fn with_transaction_restores_pragmas_after_panic() {
1315        let db = SqliteDatabase::new(&DatabaseConfig {
1316            url: ":memory:".to_string(),
1317            max_connections: 1,
1318        })
1319        .expect("db should initialize");
1320
1321        {
1322            let conn = db.conn().expect("connection should open");
1323            conn.execute_batch("PRAGMA busy_timeout = 25; PRAGMA read_uncommitted = false;")
1324                .expect("initial pragmas should be set");
1325        }
1326
1327        let panic_result = std::panic::catch_unwind(AssertUnwindSafe(|| {
1328            let _ = db.with_transaction_opts(
1329                crate::TransactionOptions::new()
1330                    .timeout_ms(250)
1331                    .isolation(crate::TransactionIsolation::ReadUncommitted),
1332                |_conn| -> std::result::Result<(), rusqlite::Error> {
1333                    panic!("boom");
1334                },
1335            );
1336        }));
1337        assert!(panic_result.is_err());
1338
1339        let conn = db.conn().expect("connection should reopen");
1340        let busy_timeout: u64 = conn
1341            .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, u64>(0))
1342            .expect("busy_timeout pragma should be readable");
1343        let read_uncommitted: i64 = conn
1344            .query_row("PRAGMA read_uncommitted", [], |row| row.get::<_, i64>(0))
1345            .expect("read_uncommitted pragma should be readable");
1346        assert_eq!(busy_timeout, 25);
1347        assert_eq!(read_uncommitted, 0);
1348    }
1349
1350    #[test]
1351    fn with_retry_respects_zero_retries() {
1352        let attempts = Cell::new(0u32);
1353        let err: std::result::Result<(), rusqlite::Error> = with_retry(
1354            || {
1355                attempts.set(attempts.get() + 1);
1356                Err(retryable_error())
1357            },
1358            0,
1359        );
1360
1361        assert!(err.is_err());
1362        assert_eq!(attempts.get(), 1);
1363    }
1364
1365    #[test]
1366    fn with_retry_retries_until_success() {
1367        let attempts = Cell::new(0u32);
1368        let result = with_retry(
1369            || {
1370                let attempt = attempts.get() + 1;
1371                attempts.set(attempt);
1372                if attempt < 3 { Err(retryable_error()) } else { Ok(attempt) }
1373            },
1374            5,
1375        )
1376        .expect("operation should succeed after retries");
1377
1378        assert_eq!(result, 3);
1379        assert_eq!(attempts.get(), 3);
1380    }
1381
1382    #[test]
1383    fn retry_jitter_is_decorrelated_across_threads() {
1384        // Concurrent writers hitting SQLITE_BUSY must not back off in lockstep:
1385        // each thread's jitter sequence has to differ, or contenders re-collide
1386        // every round and the jitter is decorative.
1387        let sequences: Vec<Vec<u64>> = (0..4)
1388            .map(|_| {
1389                std::thread::spawn(|| (1..=8).map(retry_jitter).collect::<Vec<u64>>())
1390                    .join()
1391                    .expect("jitter thread panicked")
1392            })
1393            .collect();
1394
1395        let distinct: std::collections::HashSet<&Vec<u64>> = sequences.iter().collect();
1396        assert!(
1397            distinct.len() > 1,
1398            "all {} threads drew the identical jitter sequence {:?}",
1399            sequences.len(),
1400            sequences[0]
1401        );
1402    }
1403}