1use std::collections::HashMap;
54use std::fmt;
55use std::path::Path;
56use std::sync::atomic::{AtomicU64, Ordering};
57use std::sync::{Arc, Mutex, RwLock};
58
59use rusqlite::{params, Connection, OptionalExtension};
60
61use crate::config::{Credential, Gateway};
62use crate::state::migrations::{migrations, SchemaInfo, LATEST_SCHEMA_VERSION};
63
64const QUOTA_RETENTION_SECS: i64 = 365 * 24 * 60 * 60;
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum CredentialKind {
74 ApiKey,
75 Jwt,
76 Mtls,
77 Hmac,
83 Oidc,
88}
89
90impl CredentialKind {
91 pub fn as_str(self) -> &'static str {
94 match self {
95 CredentialKind::ApiKey => "api_key",
96 CredentialKind::Jwt => "jwt",
97 CredentialKind::Mtls => "mtls",
98 CredentialKind::Hmac => "hmac",
99 CredentialKind::Oidc => "oidc",
100 }
101 }
102
103 fn from_str(s: &str) -> Option<Self> {
104 match s {
105 "api_key" => Some(CredentialKind::ApiKey),
106 "jwt" => Some(CredentialKind::Jwt),
107 "mtls" => Some(CredentialKind::Mtls),
108 "hmac" => Some(CredentialKind::Hmac),
109 "oidc" => Some(CredentialKind::Oidc),
110 _ => None,
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct ConsumerRecord {
118 pub id: i64,
119 pub name: String,
120 pub priority: Option<u8>,
121 pub groups: Vec<String>,
127 pub created_at: i64,
129}
130
131#[derive(Clone, PartialEq, Eq)]
139pub struct CredentialRecord {
140 pub id: i64,
141 pub consumer_id: i64,
142 pub consumer_name: String,
144 pub kind: CredentialKind,
145 pub hash: String,
146 pub salt: Option<String>,
147 pub selector: String,
149 pub created_at: i64,
151 pub revoked_at: Option<i64>,
153 pub retire_at: Option<i64>,
158}
159
160impl fmt::Debug for CredentialRecord {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 f.debug_struct("CredentialRecord")
163 .field("id", &self.id)
164 .field("consumer_id", &self.consumer_id)
165 .field("consumer_name", &self.consumer_name)
166 .field("kind", &self.kind)
167 .field("hash", &"[redacted]")
168 .field("salt", &self.salt.as_ref().map(|_| "[redacted]"))
169 .field("selector", &"[redacted]")
170 .field("created_at", &self.created_at)
171 .field("revoked_at", &self.revoked_at)
172 .finish()
173 }
174}
175
176#[derive(Debug)]
178pub enum StoreError {
179 Sqlite(String),
181 QuotaExceeded {
183 used: u64,
184 limit: u64,
185 requested: u64,
186 },
187 UnknownConsumer(String),
190}
191
192impl std::fmt::Display for StoreError {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 match self {
195 StoreError::Sqlite(m) => write!(f, "state store error: {m}"),
196 StoreError::QuotaExceeded {
197 used,
198 limit,
199 requested,
200 } => write!(
201 f,
202 "quota exceeded: used {used} + requested {requested} > limit {limit}"
203 ),
204 StoreError::UnknownConsumer(name) => {
205 write!(f, "unknown consumer: {name}")
206 }
207 }
208 }
209}
210
211impl std::error::Error for StoreError {}
212
213impl From<rusqlite::Error> for StoreError {
214 fn from(e: rusqlite::Error) -> Self {
215 StoreError::Sqlite(e.to_string())
216 }
217}
218
219impl From<std::io::Error> for StoreError {
220 fn from(e: std::io::Error) -> Self {
221 StoreError::Sqlite(format!("state store io error: {e}"))
222 }
223}
224
225type Result<T> = std::result::Result<T, StoreError>;
226
227#[derive(Default)]
234struct HotCache {
235 credentials: RwLock<HashMap<String, Arc<Vec<Arc<CredentialRecord>>>>>,
236 consumers: RwLock<HashMap<String, Arc<ConsumerRecord>>>,
237}
238
239pub struct StateStore {
247 conn: Mutex<Connection>,
248 cache: HotCache,
249 disk_reads: AtomicU64,
250 cache_hits: AtomicU64,
251}
252
253impl fmt::Debug for StateStore {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 f.debug_struct("StateStore").finish_non_exhaustive()
258 }
259}
260
261impl StateStore {
262 pub fn open(path: &Path) -> Result<Self> {
289 let conn = Connection::open(path)?;
290 #[cfg(unix)]
291 {
292 use std::os::unix::fs::PermissionsExt;
293 let mode = std::fs::metadata(path)?.permissions().mode();
294 if mode & 0o077 != 0 {
295 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
296 }
297 }
298 Self::init(conn, Some(path))
299 }
300
301 pub fn open_in_memory() -> Result<Self> {
303 let conn = Connection::open_in_memory()?;
304 Self::init(conn, None)
305 }
306
307 fn init(conn: Connection, path: Option<&Path>) -> Result<Self> {
308 conn.pragma_update(None, "journal_mode", "WAL")?;
309 conn.pragma_update(None, "foreign_keys", "ON")?;
310 conn.busy_timeout(std::time::Duration::from_secs(5))?;
314 let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
315 if version > i64::from(LATEST_SCHEMA_VERSION) {
316 return Err(StoreError::Sqlite(format!(
317 "state db schema version {version} is newer than this build supports ({LATEST_SCHEMA_VERSION})"
318 )));
319 }
320 let mut conn = conn;
321 if version < i64::from(LATEST_SCHEMA_VERSION) {
322 if version > 0 {
327 if let Some(path) = path {
328 backup_before_migrate(&conn, path, version)?;
329 }
330 }
331 migrations()
332 .to_latest(&mut conn)
333 .map_err(|e| StoreError::Sqlite(format!("migration failed: {e}")))?;
334 }
335 Ok(Self {
336 conn: Mutex::new(conn),
337 cache: HotCache::default(),
338 disk_reads: AtomicU64::new(0),
339 cache_hits: AtomicU64::new(0),
340 })
341 }
342
343 pub fn schema_version(&self) -> Result<u32> {
347 let conn = self.conn.lock().expect("store connection poisoned");
348 let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
349 u32::try_from(v)
350 .map_err(|_| StoreError::Sqlite(format!("schema version {v} out of u32 range")))
351 }
352
353 pub fn schema_info(&self) -> Result<SchemaInfo> {
356 Ok(SchemaInfo {
357 current: self.schema_version()?,
358 latest: LATEST_SCHEMA_VERSION,
359 })
360 }
361
362 pub fn disk_reads(&self) -> u64 {
365 self.disk_reads.load(Ordering::Relaxed)
366 }
367
368 pub fn cache_hits(&self) -> u64 {
370 self.cache_hits.load(Ordering::Relaxed)
371 }
372
373 pub fn upsert_consumer(
377 &self,
378 name: &str,
379 priority: Option<u8>,
380 groups: &[String],
381 ) -> Result<ConsumerRecord> {
382 let now = now_secs();
383 let groups_json = encode_groups(groups);
384 let conn = self.conn.lock().expect("store connection poisoned");
385 let tx = conn.unchecked_transaction()?;
386 let existing: Option<i64> = tx
387 .query_row(
388 "SELECT id FROM consumers WHERE name = ?1",
389 params![name],
390 |r| r.get(0),
391 )
392 .optional()?;
393 let id = match existing {
394 Some(id) => {
395 tx.execute(
396 "UPDATE consumers SET priority = ?2, groups = ?3 WHERE id = ?1",
397 params![id, priority, groups_json],
398 )?;
399 id
400 }
401 None => {
402 tx.execute(
403 "INSERT INTO consumers (name, priority, groups, created_at)
404 VALUES (?1, ?2, ?3, ?4)",
405 params![name, priority, groups_json, now],
406 )?;
407 tx.last_insert_rowid()
408 }
409 };
410 tx.commit()?;
411 let record = conn.query_row(
415 "SELECT id, name, priority, groups, created_at FROM consumers WHERE id = ?1",
416 params![id],
417 row_to_consumer,
418 )?;
419 drop(conn);
420 self.cache
421 .consumers
422 .write()
423 .expect("consumer cache poisoned")
424 .insert(name.to_string(), Arc::new(record.clone()));
425 Ok(record)
426 }
427
428 pub fn list_consumers(&self) -> Result<Vec<ConsumerRecord>> {
430 let conn = self.conn.lock().expect("store connection poisoned");
431 let mut stmt = conn.prepare(
432 "SELECT id, name, priority, groups, created_at FROM consumers ORDER BY name",
433 )?;
434 let rows = stmt.query_map([], row_to_consumer)?;
435 rows.collect::<std::result::Result<Vec<_>, _>>()
436 .map_err(Into::into)
437 }
438
439 pub fn add_credential(
444 &self,
445 consumer_id: i64,
446 kind: CredentialKind,
447 hash: String,
448 salt: Option<String>,
449 selector: String,
450 ) -> Result<CredentialRecord> {
451 self.insert_credential(consumer_id, kind, hash, salt, selector, None)
452 }
453
454 pub fn add_credential_from_reference(
462 &self,
463 consumer_id: i64,
464 kind: CredentialKind,
465 hash: String,
466 selector: String,
467 source_ref: &str,
468 ) -> Result<CredentialRecord> {
469 self.insert_credential(consumer_id, kind, hash, None, selector, Some(source_ref))
470 }
471
472 fn insert_credential(
477 &self,
478 consumer_id: i64,
479 kind: CredentialKind,
480 hash: String,
481 salt: Option<String>,
482 selector: String,
483 source_ref: Option<&str>,
484 ) -> Result<CredentialRecord> {
485 let now = now_secs();
486 let conn = self.conn.lock().expect("store connection poisoned");
487 let consumer_name: Option<String> = conn
488 .query_row(
489 "SELECT name FROM consumers WHERE id = ?1",
490 params![consumer_id],
491 |r| r.get(0),
492 )
493 .optional()?;
494 let Some(consumer_name) = consumer_name else {
495 return Err(StoreError::UnknownConsumer(format!("id {consumer_id}")));
496 };
497 conn.execute(
498 "INSERT INTO credentials
499 (consumer_id, kind, hash, salt, selector, created_at, source_ref)
500 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
501 params![
502 consumer_id,
503 kind.as_str(),
504 hash,
505 salt,
506 selector,
507 now,
508 source_ref
509 ],
510 )?;
511 let id = conn.last_insert_rowid();
512 drop(conn);
513 self.invalidate(&selector);
514 Ok(CredentialRecord {
515 id,
516 consumer_id,
517 consumer_name,
518 kind,
519 hash,
520 salt,
521 selector: selector.clone(),
522 created_at: now,
523 revoked_at: None,
524 retire_at: None,
525 })
526 }
527
528 pub fn revoke_credential(&self, credential_id: i64) -> Result<bool> {
533 let now = now_secs();
534 let conn = self.conn.lock().expect("store connection poisoned");
535 let selector: Option<String> = conn
536 .query_row(
537 "SELECT selector FROM credentials WHERE id = ?1 AND revoked_at IS NULL",
538 params![credential_id],
539 |r| r.get(0),
540 )
541 .optional()?;
542 let Some(selector) = selector else {
543 return Ok(false);
544 };
545 conn.execute(
546 "UPDATE credentials SET revoked_at = ?2 WHERE id = ?1",
547 params![credential_id, now],
548 )?;
549 drop(conn);
550 self.invalidate(&selector);
551 Ok(true)
552 }
553
554 pub fn retire_credential(&self, credential_id: i64, at_epoch_secs: i64) -> Result<bool> {
564 let conn = self.conn.lock().expect("store connection poisoned");
565 let selector: Option<String> = conn
566 .query_row(
567 "SELECT selector FROM credentials
568 WHERE id = ?1 AND revoked_at IS NULL
569 AND (retire_at IS NULL OR retire_at > ?2)",
570 params![credential_id, at_epoch_secs],
571 |r| r.get(0),
572 )
573 .optional()?;
574 let Some(selector) = selector else {
575 return Ok(false);
576 };
577 conn.execute(
578 "UPDATE credentials SET retire_at = ?2 WHERE id = ?1",
579 params![credential_id, at_epoch_secs],
580 )?;
581 drop(conn);
582 self.invalidate(&selector);
587 Ok(true)
588 }
589
590 pub fn list_credentials_for_consumer(
595 &self,
596 consumer_name: &str,
597 ) -> Result<Vec<CredentialRecord>> {
598 let conn = self.conn.lock().expect("store connection poisoned");
599 let mut stmt = conn.prepare(
600 "SELECT c.id, c.consumer_id, k.name, c.kind, c.hash, c.salt,
601 c.selector, c.created_at, c.retire_at, c.revoked_at
602 FROM credentials c JOIN consumers k ON k.id = c.consumer_id
603 WHERE k.name = ?1
604 ORDER BY c.id DESC",
605 )?;
606 let rows = stmt.query_map(params![consumer_name], row_to_credential_full)?;
607 rows.collect::<std::result::Result<Vec<_>, _>>()
608 .map_err(Into::into)
609 }
610
611 pub fn revoke_credentials_by_source(
626 &self,
627 consumer_id: i64,
628 kind: CredentialKind,
629 source_ref: &str,
630 ) -> Result<u64> {
631 let now = now_secs();
632 let conn = self.conn.lock().expect("store connection poisoned");
633 let selectors: Vec<String> = {
634 let mut stmt = conn.prepare(
635 "SELECT selector FROM credentials
636 WHERE consumer_id = ?1 AND kind = ?2 AND source_ref = ?3
637 AND revoked_at IS NULL",
638 )?;
639 let rows = stmt.query_map(params![consumer_id, kind.as_str(), source_ref], |r| {
640 r.get(0)
641 })?;
642 rows.collect::<std::result::Result<_, _>>()?
643 };
644 if selectors.is_empty() {
645 return Ok(0);
646 }
647 let revoked = u64::try_from(conn.execute(
648 "UPDATE credentials SET revoked_at = ?4
649 WHERE consumer_id = ?1 AND kind = ?2 AND source_ref = ?3
650 AND revoked_at IS NULL",
651 params![consumer_id, kind.as_str(), source_ref, now],
652 )?)
653 .unwrap_or(u64::MAX);
654 drop(conn);
655 for selector in selectors {
656 self.invalidate(&selector);
657 }
658 Ok(revoked)
659 }
660
661 pub fn rehash_credential(&self, credential_id: i64, selector: &str, hash: &str) -> Result<()> {
673 let conn = self.conn.lock().expect("store connection poisoned");
674 conn.execute(
675 "UPDATE credentials SET hash = ?3 WHERE id = ?1 AND selector = ?2",
676 params![credential_id, selector, hash],
677 )?;
678 drop(conn);
679 self.invalidate(selector);
680 Ok(())
681 }
682
683 pub fn lookup_credentials_by_selector(&self, selector: &str) -> Result<Vec<CredentialRecord>> {
686 let conn = self.conn.lock().expect("store connection poisoned");
687 let mut stmt = conn.prepare(
688 "SELECT c.id, c.consumer_id, k.name, c.kind, c.hash, c.salt,
689 c.selector, c.created_at, c.retire_at
690 FROM credentials c JOIN consumers k ON k.id = c.consumer_id
691 WHERE c.selector = ?1 AND c.revoked_at IS NULL
692 AND (c.retire_at IS NULL OR c.retire_at > ?2)
693 ORDER BY c.id",
694 )?;
695 let rows = stmt.query_map(params![selector, now_secs()], row_to_credential)?;
696 rows.collect::<std::result::Result<Vec<_>, _>>()
697 .map_err(Into::into)
698 }
699
700 pub fn lookup_credential(
705 &self,
706 selector: &str,
707 ) -> Result<Option<Arc<Vec<Arc<CredentialRecord>>>>> {
708 {
709 let cache = self.cache.credentials.read().expect("cache poisoned");
710 if let Some(entry) = cache.get(selector).map(Arc::clone) {
711 self.cache_hits.fetch_add(1, Ordering::Relaxed);
712 return Ok(Some(entry));
713 }
714 }
715 self.disk_reads.fetch_add(1, Ordering::Relaxed);
716 let records = self.lookup_credentials_by_selector(selector)?;
717 let entry = Arc::new(
718 records
719 .into_iter()
720 .map(Arc::new)
721 .collect::<Vec<Arc<CredentialRecord>>>(),
722 );
723 self.cache
724 .credentials
725 .write()
726 .expect("cache poisoned")
727 .insert(selector.to_string(), Arc::clone(&entry));
728 Ok(Some(entry))
729 }
730
731 pub fn lookup_consumer(&self, name: &str) -> Result<Option<Arc<ConsumerRecord>>> {
736 {
737 let cache = self.cache.consumers.read().expect("cache poisoned");
738 if let Some(entry) = cache.get(name).map(Arc::clone) {
739 self.cache_hits.fetch_add(1, Ordering::Relaxed);
740 return Ok(Some(entry));
741 }
742 }
743 self.disk_reads.fetch_add(1, Ordering::Relaxed);
744 let conn = self.conn.lock().expect("store connection poisoned");
745 let row = conn
746 .query_row(
747 "SELECT id, name, priority, groups, created_at FROM consumers WHERE name = ?1",
748 params![name],
749 row_to_consumer,
750 )
751 .optional()?;
752 drop(conn);
753 let record = row.map(Arc::new);
754 if let Some(record) = &record {
755 self.cache
756 .consumers
757 .write()
758 .expect("cache poisoned")
759 .insert(name.to_string(), Arc::clone(record));
760 }
761 Ok(record)
762 }
763
764 pub fn delete_legacy_config_placeholder_credentials(&self) -> Result<u64> {
780 let conn = self.conn.lock().expect("store connection poisoned");
781 let selectors: Vec<String> = {
782 let mut stmt = conn
783 .prepare("SELECT selector FROM credentials WHERE hash LIKE 'config:api_key:%'")?;
784 let rows = stmt.query_map([], |r| r.get(0))?;
785 rows.collect::<std::result::Result<_, _>>()?
786 };
787 if selectors.is_empty() {
788 return Ok(0);
789 }
790 let deleted = u64::try_from(conn.execute(
791 "DELETE FROM credentials WHERE hash LIKE 'config:api_key:%'",
792 [],
793 )?)
794 .unwrap_or(u64::MAX);
795 drop(conn);
796 for selector in selectors {
797 self.invalidate(&selector);
798 }
799 Ok(deleted)
800 }
801
802 pub fn invalidate(&self, selector: &str) {
804 self.cache
805 .credentials
806 .write()
807 .expect("cache poisoned")
808 .remove(selector);
809 }
810
811 pub fn invalidate_all(&self) {
813 self.cache
814 .credentials
815 .write()
816 .expect("cache poisoned")
817 .clear();
818 self.cache
819 .consumers
820 .write()
821 .expect("cache poisoned")
822 .clear();
823 }
824
825 pub fn incr_quota(
831 &self,
832 consumer_id: i64,
833 counter_key: &str,
834 window_start: i64,
835 amount: u64,
836 limit: Option<u64>,
837 ) -> Result<u64> {
838 let conn = self.conn.lock().expect("store connection poisoned");
839 let exists: Option<()> = conn
842 .query_row(
843 "SELECT 1 FROM consumers WHERE id = ?1",
844 params![consumer_id],
845 |_| Ok(()),
846 )
847 .optional()?;
848 if exists.is_none() {
849 return Err(StoreError::UnknownConsumer(format!("id {consumer_id}")));
850 }
851 let tx = conn.unchecked_transaction()?;
852 tx.execute(
853 "INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
854 VALUES (?1, ?2, ?3, 0)
855 ON CONFLICT (consumer_id, counter_key, window_start) DO NOTHING",
856 params![consumer_id, counter_key, window_start],
857 )?;
858 tx.execute(
867 "DELETE FROM quota_counters
868 WHERE window_start < (SELECT MAX(window_start) FROM quota_counters) - ?1",
869 params![QUOTA_RETENTION_SECS],
870 )?;
871 let used: u64 = tx.query_row(
872 "SELECT used FROM quota_counters
873 WHERE consumer_id = ?1 AND counter_key = ?2 AND window_start = ?3",
874 params![consumer_id, counter_key, window_start],
875 sql_u64_row,
876 )?;
877 if let Some(limit) = limit {
878 let Some(next) = used.checked_add(amount) else {
879 return Err(StoreError::QuotaExceeded {
880 used,
881 limit,
882 requested: amount,
883 });
884 };
885 if next > limit {
886 return Err(StoreError::QuotaExceeded {
887 used,
888 limit,
889 requested: amount,
890 });
891 }
892 }
893 tx.execute(
894 "UPDATE quota_counters SET used = used + ?4
895 WHERE consumer_id = ?1 AND counter_key = ?2 AND window_start = ?3",
896 params![
897 consumer_id,
898 counter_key,
899 window_start,
900 i64::try_from(amount).unwrap_or(i64::MAX)
901 ],
902 )?;
903 let used_after: u64 = tx.query_row(
904 "SELECT used FROM quota_counters
905 WHERE consumer_id = ?1 AND counter_key = ?2 AND window_start = ?3",
906 params![consumer_id, counter_key, window_start],
907 sql_u64_row,
908 )?;
909 tx.commit()?;
910 Ok(used_after)
911 }
912
913 pub fn get_quota(&self, consumer_id: i64, counter_key: &str, window_start: i64) -> Result<u64> {
915 let conn = self.conn.lock().expect("store connection poisoned");
916 let used: Option<u64> = conn
917 .query_row(
918 "SELECT used FROM quota_counters
919 WHERE consumer_id = ?1 AND counter_key = ?2 AND window_start = ?3",
920 params![consumer_id, counter_key, window_start],
921 sql_u64_row,
922 )
923 .optional()?;
924 Ok(used.unwrap_or(0))
925 }
926
927 pub fn set_prompt_override(&self, prompt_name: &str, version: &str) -> Result<()> {
942 let now = now_secs();
943 let conn = self.conn.lock().expect("store connection poisoned");
944 conn.execute(
945 "INSERT INTO prompt_overrides (prompt_name, version, updated_at)
946 VALUES (?1, ?2, ?3)
947 ON CONFLICT (prompt_name) DO UPDATE SET version = ?2, updated_at = ?3",
948 params![prompt_name, version, now],
949 )?;
950 Ok(())
951 }
952
953 pub fn clear_prompt_override(&self, prompt_name: &str) -> Result<()> {
957 let conn = self.conn.lock().expect("store connection poisoned");
958 conn.execute(
959 "DELETE FROM prompt_overrides WHERE prompt_name = ?1",
960 params![prompt_name],
961 )?;
962 Ok(())
963 }
964
965 pub fn get_prompt_override(&self, prompt_name: &str) -> Result<Option<String>> {
969 let conn = self.conn.lock().expect("store connection poisoned");
970 let version: Option<String> = conn
971 .query_row(
972 "SELECT version FROM prompt_overrides WHERE prompt_name = ?1",
973 params![prompt_name],
974 |r| r.get(0),
975 )
976 .optional()?;
977 Ok(version)
978 }
979
980 pub fn list_prompt_overrides(&self) -> Result<Vec<(String, String)>> {
984 let conn = self.conn.lock().expect("store connection poisoned");
985 let mut stmt =
986 conn.prepare("SELECT prompt_name, version FROM prompt_overrides ORDER BY prompt_name")?;
987 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
988 rows.collect::<std::result::Result<Vec<_>, _>>()
989 .map_err(Into::into)
990 }
991
992 pub fn create_mcp_session(
999 &self,
1000 session_id: &str,
1001 consumer: &str,
1002 ttl_secs: u64,
1003 client_info: Option<&str>,
1004 ) -> Result<()> {
1005 let conn = self.conn.lock().expect("store connection poisoned");
1006 let now = std::time::SystemTime::now()
1007 .duration_since(std::time::UNIX_EPOCH)
1008 .map(|d| d.as_secs() as i64)
1009 .unwrap_or(0);
1010 let expires = now + ttl_secs as i64;
1011 conn.execute(
1012 "INSERT INTO mcp_sessions \
1013 (session_id, consumer, created_at, last_used_at, expires_at, client_info) \
1014 VALUES (?1, ?2, ?3, ?3, ?4, ?5)",
1015 params![session_id, consumer, now, expires, client_info],
1016 )?;
1017 Ok(())
1018 }
1019
1020 pub fn touch_mcp_session(&self, session_id: &str) -> Result<bool> {
1024 let conn = self.conn.lock().expect("store connection poisoned");
1025 let now = std::time::SystemTime::now()
1026 .duration_since(std::time::UNIX_EPOCH)
1027 .map(|d| d.as_secs() as i64)
1028 .unwrap_or(0);
1029 let expires_at: Option<i64> = conn
1030 .query_row(
1031 "SELECT expires_at FROM mcp_sessions WHERE session_id = ?1",
1032 params![session_id],
1033 |r| r.get(0),
1034 )
1035 .optional()?;
1036 match expires_at {
1037 Some(exp) if exp > now => {
1038 conn.execute(
1039 "UPDATE mcp_sessions SET last_used_at = ?1 WHERE session_id = ?2",
1040 params![now, session_id],
1041 )?;
1042 Ok(true)
1043 }
1044 Some(_) => {
1045 let _ = conn.execute(
1047 "DELETE FROM mcp_sessions WHERE session_id = ?1",
1048 params![session_id],
1049 );
1050 Ok(false)
1051 }
1052 None => Ok(false),
1053 }
1054 }
1055
1056 pub fn delete_mcp_session(&self, session_id: &str) -> Result<()> {
1059 let conn = self.conn.lock().expect("store connection poisoned");
1060 conn.execute(
1061 "DELETE FROM mcp_sessions WHERE session_id = ?1",
1062 params![session_id],
1063 )?;
1064 Ok(())
1065 }
1066
1067 pub fn count_active_mcp_sessions(&self) -> Result<usize> {
1069 let conn = self.conn.lock().expect("store connection poisoned");
1070 let now = std::time::SystemTime::now()
1071 .duration_since(std::time::UNIX_EPOCH)
1072 .map(|d| d.as_secs() as i64)
1073 .unwrap_or(0);
1074 let count: i64 = conn.query_row(
1075 "SELECT COUNT(*) FROM mcp_sessions WHERE expires_at > ?1",
1076 params![now],
1077 |r| r.get(0),
1078 )?;
1079 Ok(count as usize)
1080 }
1081
1082 pub fn cleanup_expired_mcp_sessions(&self) -> Result<usize> {
1085 let conn = self.conn.lock().expect("store connection poisoned");
1086 let now = std::time::SystemTime::now()
1087 .duration_since(std::time::UNIX_EPOCH)
1088 .map(|d| d.as_secs() as i64)
1089 .unwrap_or(0);
1090 let n = conn.execute(
1091 "DELETE FROM mcp_sessions WHERE expires_at <= ?1",
1092 params![now],
1093 )?;
1094 Ok(n)
1095 }
1096
1097 #[allow(clippy::type_complexity)]
1102 pub fn list_active_mcp_sessions(
1103 &self,
1104 ) -> Result<Vec<(String, String, i64, i64, i64, Option<String>)>> {
1105 let conn = self.conn.lock().expect("store connection poisoned");
1106 let now = std::time::SystemTime::now()
1107 .duration_since(std::time::UNIX_EPOCH)
1108 .map(|d| d.as_secs() as i64)
1109 .unwrap_or(0);
1110 let mut stmt = conn.prepare(
1111 "SELECT session_id, consumer, created_at, last_used_at, expires_at, client_info \
1112 FROM mcp_sessions WHERE expires_at > ?1 \
1113 ORDER BY created_at DESC",
1114 )?;
1115 let rows = stmt.query_map(params![now], |r| {
1116 Ok((
1117 r.get::<_, String>(0)?,
1118 r.get::<_, String>(1)?,
1119 r.get::<_, i64>(2)?,
1120 r.get::<_, i64>(3)?,
1121 r.get::<_, i64>(4)?,
1122 r.get::<_, Option<String>>(5)?,
1123 ))
1124 })?;
1125 rows.collect::<std::result::Result<Vec<_>, _>>()
1126 .map_err(Into::into)
1127 }
1128
1129 pub fn upsert_workspace(&self, name: &str, description: &str, active: bool) -> Result<()> {
1143 let conn = self.conn.lock().expect("store connection poisoned");
1144 conn.execute(
1145 "INSERT INTO workspaces (name, description, active, created_at) \
1146 VALUES (?1, ?2, ?3, ?4) \
1147 ON CONFLICT (name) DO UPDATE SET \
1148 description = excluded.description, \
1149 active = excluded.active",
1150 params![name, description, active as i64, now_secs()],
1151 )?;
1152 Ok(())
1153 }
1154
1155 pub fn delete_workspace_row(&self, name: &str) -> Result<bool> {
1157 let conn = self.conn.lock().expect("store connection poisoned");
1158 let n = conn.execute("DELETE FROM workspaces WHERE name = ?1", params![name])?;
1159 Ok(n > 0)
1160 }
1161
1162 pub fn list_workspace_rows(&self) -> Result<Vec<WorkspaceRow>> {
1164 let conn = self.conn.lock().expect("store connection poisoned");
1165 let mut stmt = conn.prepare(
1166 "SELECT name, description, active, created_at FROM workspaces ORDER BY name",
1167 )?;
1168 let rows = stmt.query_map([], |r| {
1169 Ok(WorkspaceRow {
1170 name: r.get(0)?,
1171 description: r.get(1)?,
1172 active: r.get::<_, i64>(2)? != 0,
1173 created_at: r.get(3)?,
1174 })
1175 })?;
1176 rows.collect::<std::result::Result<Vec<_>, _>>()
1177 .map_err(Into::into)
1178 }
1179
1180 pub fn get_workspace_row(&self, name: &str) -> Result<Option<WorkspaceRow>> {
1182 let conn = self.conn.lock().expect("store connection poisoned");
1183 let mut stmt = conn.prepare(
1184 "SELECT name, description, active, created_at FROM workspaces WHERE name = ?1",
1185 )?;
1186 let row = stmt
1187 .query_row(params![name], |r| {
1188 Ok(WorkspaceRow {
1189 name: r.get(0)?,
1190 description: r.get(1)?,
1191 active: r.get::<_, i64>(2)? != 0,
1192 created_at: r.get(3)?,
1193 })
1194 })
1195 .optional()?;
1196 Ok(row)
1197 }
1198
1199 pub fn upsert_role(&self, name: &str, permissions_json: &str) -> Result<()> {
1202 let conn = self.conn.lock().expect("store connection poisoned");
1203 conn.execute(
1204 "INSERT INTO rbac_roles (name, permissions, created_at) \
1205 VALUES (?1, ?2, ?3) \
1206 ON CONFLICT (name) DO UPDATE SET permissions = excluded.permissions",
1207 params![name, permissions_json, now_secs()],
1208 )?;
1209 Ok(())
1210 }
1211
1212 pub fn delete_role_row(&self, name: &str) -> Result<bool> {
1214 let conn = self.conn.lock().expect("store connection poisoned");
1215 let n = conn.execute("DELETE FROM rbac_roles WHERE name = ?1", params![name])?;
1216 Ok(n > 0)
1217 }
1218
1219 pub fn list_role_rows(&self) -> Result<Vec<RoleRow>> {
1221 let conn = self.conn.lock().expect("store connection poisoned");
1222 let mut stmt =
1223 conn.prepare("SELECT name, permissions, created_at FROM rbac_roles ORDER BY name")?;
1224 let rows = stmt.query_map([], |r| {
1225 Ok(RoleRow {
1226 name: r.get(0)?,
1227 permissions_json: r.get(1)?,
1228 created_at: r.get(2)?,
1229 })
1230 })?;
1231 rows.collect::<std::result::Result<Vec<_>, _>>()
1232 .map_err(Into::into)
1233 }
1234
1235 pub fn upsert_principal(&self, identity: &str, roles_json: &str) -> Result<()> {
1238 let conn = self.conn.lock().expect("store connection poisoned");
1239 conn.execute(
1240 "INSERT INTO rbac_principals (identity, roles, created_at) \
1241 VALUES (?1, ?2, ?3) \
1242 ON CONFLICT (identity) DO UPDATE SET roles = excluded.roles",
1243 params![identity, roles_json, now_secs()],
1244 )?;
1245 Ok(())
1246 }
1247
1248 pub fn get_principal_row(&self, identity: &str) -> Result<Option<PrincipalRow>> {
1250 let conn = self.conn.lock().expect("store connection poisoned");
1251 let row = conn
1252 .query_row(
1253 "SELECT identity, roles, created_at FROM rbac_principals WHERE identity = ?1",
1254 params![identity],
1255 |r| {
1256 Ok(PrincipalRow {
1257 identity: r.get(0)?,
1258 roles_json: r.get(1)?,
1259 created_at: r.get(2)?,
1260 })
1261 },
1262 )
1263 .optional()?;
1264 Ok(row)
1265 }
1266
1267 pub fn list_principal_rows(&self) -> Result<Vec<PrincipalRow>> {
1269 let conn = self.conn.lock().expect("store connection poisoned");
1270 let mut stmt = conn
1271 .prepare("SELECT identity, roles, created_at FROM rbac_principals ORDER BY identity")?;
1272 let rows = stmt.query_map([], |r| {
1273 Ok(PrincipalRow {
1274 identity: r.get(0)?,
1275 roles_json: r.get(1)?,
1276 created_at: r.get(2)?,
1277 })
1278 })?;
1279 rows.collect::<std::result::Result<Vec<_>, _>>()
1280 .map_err(Into::into)
1281 }
1282
1283 #[allow(clippy::too_many_arguments)]
1285 pub fn append_workspace_audit(
1286 &self,
1287 timestamp_ms: i64,
1288 principal: &str,
1289 action: &str,
1290 workspace: &str,
1291 before: Option<&str>,
1292 after: Option<&str>,
1293 request_id: &str,
1294 ) -> Result<i64> {
1295 let conn = self.conn.lock().expect("store connection poisoned");
1296 conn.execute(
1297 "INSERT INTO workspace_audit \
1298 (timestamp_ms, principal, action, workspace, before_state, after_state, request_id) \
1299 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1300 params![
1301 timestamp_ms,
1302 principal,
1303 action,
1304 workspace,
1305 before,
1306 after,
1307 request_id
1308 ],
1309 )?;
1310 Ok(conn.last_insert_rowid())
1311 }
1312
1313 pub fn query_workspace_audit(
1317 &self,
1318 workspace: Option<&str>,
1319 since_ms: Option<i64>,
1320 until_ms: Option<i64>,
1321 limit: i64,
1322 ) -> Result<Vec<AuditRow>> {
1323 let conn = self.conn.lock().expect("store connection poisoned");
1324 let mut sql = String::from(
1325 "SELECT seq, timestamp_ms, principal, action, workspace, before_state, after_state, request_id \
1326 FROM workspace_audit WHERE 1=1",
1327 );
1328 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1329 if let Some(ws) = workspace {
1330 sql.push_str(" AND workspace = ?");
1331 params_vec.push(Box::new(ws.to_string()));
1332 }
1333 if let Some(since) = since_ms {
1334 sql.push_str(" AND timestamp_ms >= ?");
1335 params_vec.push(Box::new(since));
1336 }
1337 if let Some(until) = until_ms {
1338 sql.push_str(" AND timestamp_ms <= ?");
1339 params_vec.push(Box::new(until));
1340 }
1341 sql.push_str(" ORDER BY seq DESC LIMIT ?");
1342 params_vec.push(Box::new(limit));
1343 let mut stmt = conn.prepare(&sql)?;
1344 let params_ref: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1345 let rows = stmt.query_map(params_ref.as_slice(), |r| {
1346 Ok(AuditRow {
1347 seq: r.get(0)?,
1348 timestamp_ms: r.get(1)?,
1349 principal: r.get(2)?,
1350 action: r.get(3)?,
1351 workspace: r.get(4)?,
1352 before_state: r.get(5)?,
1353 after_state: r.get(6)?,
1354 request_id: r.get(7)?,
1355 })
1356 })?;
1357 rows.collect::<std::result::Result<Vec<_>, _>>()
1358 .map_err(Into::into)
1359 }
1360
1361 pub fn try_acquire_leader(&self, instance_id: &str, ttl_ms: i64) -> Result<LeaderRow> {
1372 let conn = self.conn.lock().expect("store connection poisoned");
1373 let now = now_unix_ms();
1374 let expires_at = now + ttl_ms;
1375
1376 let existing: Option<(String, i64, i64)> = conn
1378 .query_row(
1379 "SELECT instance_id, epoch, expires_at_ms \
1380 FROM controller_leader WHERE key = 'leader'",
1381 [],
1382 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1383 )
1384 .ok();
1385
1386 match existing {
1387 Some((id, _epoch, expires)) if expires > now && id != instance_id => {
1388 Err(StoreError::Sqlite(format!("{id}:{expires}")))
1390 }
1391 Some((id, epoch, _)) if id == instance_id => {
1392 let new_epoch = epoch;
1394 conn.execute(
1395 "UPDATE controller_leader \
1396 SET expires_at_ms = ?1 \
1397 WHERE key = 'leader' AND instance_id = ?2",
1398 params![expires_at, instance_id],
1399 )?;
1400 Ok(LeaderRow {
1401 instance_id: instance_id.to_string(),
1402 epoch: new_epoch as u64,
1403 acquired_at_ms: now,
1404 expires_at_ms: expires_at,
1405 })
1406 }
1407 _ => {
1408 let epoch = existing.map(|(_, e, _)| e + 1).unwrap_or(1);
1410 conn.execute(
1411 "INSERT OR REPLACE INTO controller_leader \
1412 (key, instance_id, epoch, acquired_at_ms, expires_at_ms) \
1413 VALUES ('leader', ?1, ?2, ?3, ?4)",
1414 params![instance_id, epoch, now, expires_at],
1415 )?;
1416 Ok(LeaderRow {
1417 instance_id: instance_id.to_string(),
1418 epoch: epoch as u64,
1419 acquired_at_ms: now,
1420 expires_at_ms: expires_at,
1421 })
1422 }
1423 }
1424 }
1425
1426 pub fn renew_leader_lease(&self, instance_id: &str, ttl_ms: i64) -> Result<LeaderRow> {
1430 let conn = self.conn.lock().expect("store connection poisoned");
1431 let now = now_unix_ms();
1432 let expires_at = now + ttl_ms;
1433
1434 let updated = conn.execute(
1435 "UPDATE controller_leader \
1436 SET expires_at_ms = ?1 \
1437 WHERE key = 'leader' AND instance_id = ?2 AND expires_at_ms > ?3",
1438 params![expires_at, instance_id, now],
1439 )?;
1440
1441 if updated == 0 {
1442 return Err(StoreError::Sqlite(
1444 "lease lost: no valid lease held by this instance".to_string(),
1445 ));
1446 }
1447
1448 let row = conn.query_row(
1450 "SELECT instance_id, epoch, acquired_at_ms, expires_at_ms \
1451 FROM controller_leader WHERE key = 'leader'",
1452 [],
1453 |r| {
1454 Ok(LeaderRow {
1455 instance_id: r.get(0)?,
1456 epoch: r.get::<_, i64>(1)? as u64,
1457 acquired_at_ms: r.get(2)?,
1458 expires_at_ms: r.get(3)?,
1459 })
1460 },
1461 )?;
1462 Ok(row)
1463 }
1464
1465 pub fn step_down_leader(&self, instance_id: &str) -> Result<()> {
1469 let conn = self.conn.lock().expect("store connection poisoned");
1470 let deleted = conn.execute(
1471 "DELETE FROM controller_leader \
1472 WHERE key = 'leader' AND instance_id = ?1",
1473 params![instance_id],
1474 )?;
1475 if deleted == 0 {
1476 return Err(StoreError::Sqlite(
1477 "step_down failed: this instance does not hold the lease".to_string(),
1478 ));
1479 }
1480 Ok(())
1481 }
1482
1483 pub fn current_leader(&self) -> Result<Option<LeaderRow>> {
1486 let conn = self.conn.lock().expect("store connection poisoned");
1487 let now = now_unix_ms();
1488 let row = conn
1489 .query_row(
1490 "SELECT instance_id, epoch, acquired_at_ms, expires_at_ms \
1491 FROM controller_leader WHERE key = 'leader' AND expires_at_ms > ?1",
1492 params![now],
1493 |r| {
1494 Ok(LeaderRow {
1495 instance_id: r.get(0)?,
1496 epoch: r.get::<_, i64>(1)? as u64,
1497 acquired_at_ms: r.get(2)?,
1498 expires_at_ms: r.get(3)?,
1499 })
1500 },
1501 )
1502 .ok();
1503 match row {
1505 Some(r) => Ok(Some(r)),
1506 None => {
1507 let count: i64 = conn
1509 .query_row(
1510 "SELECT COUNT(*) FROM controller_leader WHERE key = 'leader'",
1511 [],
1512 |r| r.get(0),
1513 )
1514 .unwrap_or(0);
1515 if count == 0 {
1516 Ok(None)
1517 } else {
1518 Ok(None)
1520 }
1521 }
1522 }
1523 }
1524
1525 pub fn save_generation_counter(&self, generation: u64) -> Result<()> {
1529 let conn = self.conn.lock().expect("store connection poisoned");
1530 let now = now_unix_ms();
1531 conn.execute(
1532 "INSERT OR REPLACE INTO generation_counter (key, generation, updated_at_ms) \
1533 VALUES ('cp', ?1, ?2)",
1534 params![generation as i64, now],
1535 )?;
1536 Ok(())
1537 }
1538
1539 pub fn load_generation_counter(&self) -> Result<Option<u64>> {
1542 let conn = self.conn.lock().expect("store connection poisoned");
1543 let row = conn
1544 .query_row(
1545 "SELECT generation FROM generation_counter WHERE key = 'cp'",
1546 [],
1547 |r| r.get::<_, i64>(0),
1548 )
1549 .ok();
1550 Ok(row.map(|v| v as u64))
1551 }
1552
1553 pub fn append_event_wal(
1562 &self,
1563 id: &str,
1564 kind: &str,
1565 gateway: &str,
1566 timestamp_ms: u64,
1567 payload_json: &str,
1568 ) -> Result<()> {
1569 let conn = self.conn.lock().expect("store connection poisoned");
1570 conn.execute(
1571 "INSERT OR REPLACE INTO event_wal (id, kind, gateway, timestamp_ms, payload, acked) \
1572 VALUES (?1, ?2, ?3, ?4, ?5, 0)",
1573 params![id, kind, gateway, timestamp_ms as i64, payload_json],
1574 )?;
1575 Ok(())
1576 }
1577
1578 pub fn ack_event_wal(&self, id: &str) -> Result<usize> {
1583 let conn = self.conn.lock().expect("store connection poisoned");
1584 let n = conn.execute(
1585 "UPDATE event_wal SET acked = 1 WHERE id = ?1 AND acked = 0",
1586 params![id],
1587 )?;
1588 Ok(n)
1589 }
1590
1591 pub fn unacked_events_wal(&self) -> Result<Vec<EventWalRow>> {
1595 let conn = self.conn.lock().expect("store connection poisoned");
1596 let mut stmt = conn.prepare(
1597 "SELECT id, kind, gateway, timestamp_ms, payload \
1598 FROM event_wal WHERE acked = 0 ORDER BY timestamp_ms ASC",
1599 )?;
1600 let rows = stmt.query_map([], |r| {
1601 Ok(EventWalRow {
1602 id: r.get(0)?,
1603 kind: r.get(1)?,
1604 gateway: r.get(2)?,
1605 timestamp_ms: r.get(3)?,
1606 payload: r.get(4)?,
1607 })
1608 })?;
1609 let mut result = Vec::new();
1610 for row in rows {
1611 result.push(row?);
1612 }
1613 Ok(result)
1614 }
1615
1616 pub fn purge_acked_events_wal(&self, older_than_ms: u64) -> Result<usize> {
1620 let conn = self.conn.lock().expect("store connection poisoned");
1621 let n = conn.execute(
1622 "DELETE FROM event_wal WHERE acked = 1 AND timestamp_ms < ?1",
1623 params![older_than_ms as i64],
1624 )?;
1625 Ok(n)
1626 }
1627}
1628
1629#[derive(Debug, Clone, PartialEq, Eq)]
1633pub struct WorkspaceRow {
1634 pub name: String,
1635 pub description: String,
1636 pub active: bool,
1637 pub created_at: i64,
1638}
1639
1640#[derive(Debug, Clone, PartialEq, Eq)]
1643pub struct RoleRow {
1644 pub name: String,
1645 pub permissions_json: String,
1646 pub created_at: i64,
1647}
1648
1649#[derive(Debug, Clone, PartialEq, Eq)]
1652pub struct PrincipalRow {
1653 pub identity: String,
1654 pub roles_json: String,
1655 pub created_at: i64,
1656}
1657
1658#[derive(Debug, Clone, PartialEq, Eq)]
1661pub struct AuditRow {
1662 pub seq: i64,
1663 pub timestamp_ms: i64,
1664 pub principal: String,
1665 pub action: String,
1666 pub workspace: String,
1667 pub before_state: Option<String>,
1668 pub after_state: Option<String>,
1669 pub request_id: String,
1670}
1671
1672#[derive(Debug, Clone, PartialEq, Eq)]
1676pub struct LeaderRow {
1677 pub instance_id: String,
1678 pub epoch: u64,
1679 pub acquired_at_ms: i64,
1680 pub expires_at_ms: i64,
1681}
1682
1683#[derive(Debug, Clone, PartialEq, Eq)]
1687pub struct EventWalRow {
1688 pub id: String,
1689 pub kind: String,
1690 pub gateway: String,
1691 pub timestamp_ms: i64,
1692 pub payload: String,
1693}
1694
1695fn sql_u64_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<u64> {
1699 let v: i64 = r.get(0)?;
1700 u64::try_from(v).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, v))
1701}
1702
1703fn backup_file_name(path: &Path, version: i64, now: std::time::SystemTime) -> String {
1709 let dur = now
1710 .duration_since(std::time::UNIX_EPOCH)
1711 .unwrap_or_default();
1712 format!(
1713 "{}.bak-{version}-{}-{}",
1714 path.file_name()
1715 .map(std::ffi::OsStr::to_string_lossy)
1716 .unwrap_or_else(|| "state.db".into()),
1717 dur.as_secs(),
1718 dur.subsec_millis()
1719 )
1720}
1721
1722fn backup_before_migrate(conn: &Connection, path: &Path, version: i64) -> Result<()> {
1730 let file_name = backup_file_name(path, version, std::time::SystemTime::now());
1731 let backup_path = path.with_file_name(file_name);
1732 let escaped = backup_path.to_string_lossy().replace('\'', "''");
1734 conn.execute_batch(&format!("VACUUM INTO '{escaped}';"))
1735 .map_err(|e| {
1736 let _ = std::fs::remove_file(&backup_path);
1740 StoreError::Sqlite(format!(
1741 "refusing to migrate: backup to {} failed: {e}",
1742 backup_path.display()
1743 ))
1744 })?;
1745 Ok(())
1746}
1747
1748fn now_secs() -> i64 {
1749 std::time::SystemTime::now()
1750 .duration_since(std::time::UNIX_EPOCH)
1751 .map(|d| d.as_secs() as i64)
1752 .unwrap_or(0)
1753}
1754
1755fn now_unix_ms() -> i64 {
1757 std::time::SystemTime::now()
1758 .duration_since(std::time::UNIX_EPOCH)
1759 .map(|d| d.as_millis() as i64)
1760 .unwrap_or(0)
1761}
1762
1763fn row_to_consumer(r: &rusqlite::Row<'_>) -> rusqlite::Result<ConsumerRecord> {
1764 let groups_raw: Option<String> = r.get(3)?;
1765 Ok(ConsumerRecord {
1766 id: r.get(0)?,
1767 name: r.get(1)?,
1768 priority: r.get(2)?,
1769 groups: decode_groups(groups_raw.as_deref()),
1770 created_at: r.get(4)?,
1771 })
1772}
1773
1774fn encode_groups(groups: &[String]) -> String {
1778 serde_json::to_string(groups).unwrap_or_else(|_| "[]".to_string())
1779}
1780
1781fn decode_groups(raw: Option<&str>) -> Vec<String> {
1787 match raw {
1788 None => Vec::new(),
1789 Some(s) => serde_json::from_str(s).unwrap_or_else(|_| {
1790 tracing::warn!(
1791 code = "consumer_groups_unparseable",
1792 "consumers.groups is not a JSON array of strings; treating as no groups"
1793 );
1794 Vec::new()
1795 }),
1796 }
1797}
1798
1799fn row_to_credential(r: &rusqlite::Row<'_>) -> rusqlite::Result<CredentialRecord> {
1800 let kind_str: String = r.get(3)?;
1801 Ok(CredentialRecord {
1802 id: r.get(0)?,
1803 consumer_id: r.get(1)?,
1804 consumer_name: r.get(2)?,
1805 kind: CredentialKind::from_str(&kind_str).ok_or_else(|| {
1806 rusqlite::Error::FromSqlConversionFailure(
1807 3,
1808 rusqlite::types::Type::Text,
1809 format!("unknown credential kind {kind_str}").into(),
1810 )
1811 })?,
1812 hash: r.get(4)?,
1813 salt: r.get(5)?,
1814 selector: r.get(6)?,
1815 created_at: r.get(7)?,
1816 revoked_at: None,
1817 retire_at: r.get(8)?,
1818 })
1819}
1820
1821fn row_to_credential_full(r: &rusqlite::Row<'_>) -> rusqlite::Result<CredentialRecord> {
1825 let hot = row_to_credential(r)?;
1826 Ok(CredentialRecord {
1827 revoked_at: r.get(9)?,
1828 ..hot
1829 })
1830}
1831
1832pub fn sync_consumers_from_config(
1868 store: &StateStore,
1869 gateway: &Gateway,
1870 pepper: Option<&[u8]>,
1871) -> Result<()> {
1872 store.delete_legacy_config_placeholder_credentials()?;
1873 for consumer in &gateway.consumers {
1874 let record = store.upsert_consumer(&consumer.name, consumer.priority, &consumer.groups)?;
1875 for credential in &consumer.credentials {
1876 if matches!(credential, Credential::Hmac { .. }) {
1883 continue;
1884 }
1885 let source_ref = match credential {
1896 Credential::ApiKey { key } => {
1897 match crate::config::credentials::parse_secret_reference(key) {
1898 Some(Ok(_)) => Some(key.clone()),
1899 _ => None,
1900 }
1901 }
1902 _ => None,
1903 };
1904 let (kind, selector, hash) = match credential_parts(credential, pepper) {
1905 Ok(parts) => parts,
1906 Err(err) => {
1907 tracing::error!(
1908 code = "config_api_key_unresolvable",
1909 consumer = %consumer.name,
1910 "skipping credential seed (secret reference unresolvable): {err}"
1911 );
1912 if let Some(source) = &source_ref {
1913 let retired = store.revoke_credentials_by_source(
1914 record.id,
1915 CredentialKind::ApiKey,
1916 source,
1917 )?;
1918 if retired > 0 {
1919 tracing::warn!(
1920 code = "config_api_key_previous_generation_revoked",
1921 consumer = %consumer.name,
1922 "revoked {retired} previous-generation credential row(s) \
1923 seeded from the now-unresolvable reference (the old key \
1924 stops authenticating)"
1925 );
1926 }
1927 }
1928 continue;
1929 }
1930 };
1931 let existing = store.lookup_credentials_by_selector(&selector)?;
1932 if existing
1933 .iter()
1934 .any(|c| c.consumer_id == record.id && c.kind == kind)
1935 {
1936 continue;
1937 }
1938 match &source_ref {
1939 Some(source) => {
1940 store.add_credential_from_reference(
1941 record.id,
1942 kind,
1943 hash,
1944 selector.clone(),
1945 source,
1946 )?;
1947 }
1948 None => {
1949 store.add_credential(record.id, kind, hash, None, selector.clone())?;
1950 }
1951 }
1952 }
1953 }
1954 Ok(())
1955}
1956
1957fn credential_parts(
1962 credential: &Credential,
1963 pepper: Option<&[u8]>,
1964) -> std::result::Result<(CredentialKind, String, String), String> {
1965 match credential {
1966 Credential::ApiKey { key } => {
1967 let key = crate::config::credentials::resolve_configured_secret(key)?;
1968 let selector = crate::config::credentials::credential_selector(&key);
1969 let hash = match pepper {
1970 Some(pepper) => crate::config::credentials::hmac_stored_hash(pepper, &key),
1971 None => crate::config::credentials::sha256_stored_hash(&key),
1972 };
1973 Ok((CredentialKind::ApiKey, selector, hash))
1974 }
1975 Credential::Jwt { issuer, .. } => Ok((
1976 CredentialKind::Jwt,
1977 issuer.clone(),
1978 format!("config:jwt:{issuer}"),
1979 )),
1980 Credential::Hmac { .. } => {
1986 Err("hmac credentials are config-served only (no store row)".to_string())
1987 }
1988 Credential::Mtls {
1995 subject,
1996 fingerprint,
1997 } => {
1998 let match_value = subject
1999 .clone()
2000 .or_else(|| fingerprint.clone())
2001 .unwrap_or_default();
2002 Ok((
2003 CredentialKind::Mtls,
2004 match_value.clone(),
2005 format!("config:mtls:{match_value}"),
2006 ))
2007 }
2008 }
2009}
2010
2011#[cfg(test)]
2015mod tests {
2016 use super::*;
2017 fn seeded_store() -> StateStore {
2018 let store = StateStore::open_in_memory().unwrap();
2019 store.upsert_consumer("acme", Some(7), &[]).unwrap();
2020 store
2021 }
2022 #[test]
2023 fn quota_prunes_rows_older_than_retention_horizon() {
2024 let store = seeded_store();
2025 let consumer = store.lookup_consumer("acme").unwrap().unwrap();
2026 let now = now_secs();
2027 {
2030 let conn = store.conn.lock().unwrap();
2031 conn.execute(
2032 "INSERT INTO quota_counters VALUES (?1, 'rpm', ?2, 42)",
2033 params![consumer.id, now - 2 * QUOTA_RETENTION_SECS],
2034 )
2035 .unwrap();
2036 conn.execute(
2037 "INSERT INTO quota_counters VALUES (?1, 'rpm', ?2, 0)
2038 ON CONFLICT (consumer_id, counter_key, window_start) DO NOTHING",
2039 params![consumer.id, now],
2040 )
2041 .unwrap();
2042 }
2043 assert_eq!(
2044 store
2045 .get_quota(consumer.id, "rpm", now - 2 * QUOTA_RETENTION_SECS)
2046 .unwrap(),
2047 42
2048 );
2049 store.incr_quota(consumer.id, "rpm", now, 1, None).unwrap();
2050 assert_eq!(
2051 store
2052 .get_quota(consumer.id, "rpm", now - 2 * QUOTA_RETENTION_SECS)
2053 .unwrap(),
2054 0
2055 );
2056 assert_eq!(store.get_quota(consumer.id, "rpm", now).unwrap(), 1);
2057 }
2058
2059 fn build_v1_db(path: &Path) {
2064 let conn = Connection::open(path).unwrap();
2065 conn.execute_batch(
2066 "BEGIN;
2067 CREATE TABLE consumers (
2068 id INTEGER PRIMARY KEY AUTOINCREMENT,
2069 name TEXT NOT NULL UNIQUE,
2070 priority INTEGER,
2071 created_at INTEGER NOT NULL
2072 );
2073 CREATE TABLE credentials (
2074 id INTEGER PRIMARY KEY AUTOINCREMENT,
2075 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2076 kind TEXT NOT NULL CHECK (kind IN ('api_key', 'jwt', 'mtls')),
2077 hash TEXT NOT NULL,
2078 salt TEXT,
2079 selector TEXT NOT NULL,
2080 created_at INTEGER NOT NULL,
2081 revoked_at INTEGER
2082 );
2083 CREATE INDEX idx_credentials_selector ON credentials (selector);
2084 CREATE TABLE quota_counters (
2085 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2086 counter_key TEXT NOT NULL,
2087 window_start INTEGER NOT NULL,
2088 used INTEGER NOT NULL DEFAULT 0,
2089 PRIMARY KEY (consumer_id, counter_key, window_start)
2090 );
2091 INSERT INTO consumers (name, priority, created_at) VALUES ('acme', 5, 1000);
2092 INSERT INTO credentials
2093 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2094 VALUES (1, 'api_key', 'h1', NULL, 'key-1', 1001, NULL);
2095 INSERT INTO credentials
2096 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2097 VALUES (1, 'jwt', 'h2', 's2', 'key-2', 1002, 5555);
2098 INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2099 VALUES (1, 'rpm', 3600, 42);
2100 PRAGMA user_version = 1;
2101 COMMIT;",
2102 )
2103 .unwrap();
2104 }
2105 #[test]
2106 fn v1_db_migrates_to_latest_with_zero_data_loss() {
2107 let dir = tempfile::tempdir().unwrap();
2108 let path = dir.path().join("state.db");
2109 build_v1_db(&path);
2110
2111 let store = StateStore::open(&path).unwrap();
2113 assert_eq!(store.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2114 let consumers = store.list_consumers().unwrap();
2116 assert_eq!(consumers.len(), 1);
2117 assert_eq!(consumers[0].name, "acme");
2118 assert_eq!(consumers[0].priority, Some(5));
2119 assert_eq!(consumers[0].created_at, 1000);
2120 assert_eq!(consumers[0].groups, Vec::<String>::new());
2122 let active = store.lookup_credentials_by_selector("key-1").unwrap();
2124 assert_eq!(active.len(), 1);
2125 assert_eq!(active[0].hash, "h1");
2126 assert_eq!(active[0].kind, CredentialKind::ApiKey);
2127 assert_eq!(store.get_quota(1, "rpm", 3600).unwrap(), 42);
2129 {
2131 let conn = store.conn.lock().unwrap();
2132 let n: i64 = conn
2133 .query_row(
2134 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index'
2135 AND name = 'idx_quota_counters_window'",
2136 [],
2137 |r| r.get(0),
2138 )
2139 .unwrap();
2140 assert_eq!(n, 1);
2141 }
2142 store
2144 .upsert_consumer("acme", Some(6), &["partners".to_string()])
2145 .unwrap();
2146 assert_eq!(store.list_consumers().unwrap()[0].priority, Some(6));
2147 assert_eq!(
2148 store.list_consumers().unwrap()[0].groups,
2149 vec!["partners".to_string()]
2150 );
2151 }
2152 fn build_v2_db(path: &Path) {
2157 let conn = Connection::open(path).unwrap();
2158 conn.execute_batch(
2159 "BEGIN;
2160 CREATE TABLE consumers (
2161 id INTEGER PRIMARY KEY AUTOINCREMENT,
2162 name TEXT NOT NULL UNIQUE,
2163 priority INTEGER,
2164 created_at INTEGER NOT NULL
2165 );
2166 CREATE TABLE credentials (
2167 id INTEGER PRIMARY KEY AUTOINCREMENT,
2168 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2169 kind TEXT NOT NULL CHECK (kind IN ('api_key', 'jwt', 'mtls')),
2170 hash TEXT NOT NULL,
2171 salt TEXT,
2172 selector TEXT NOT NULL,
2173 created_at INTEGER NOT NULL,
2174 revoked_at INTEGER
2175 );
2176 CREATE INDEX idx_credentials_selector ON credentials (selector);
2177 CREATE TABLE quota_counters (
2178 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2179 counter_key TEXT NOT NULL,
2180 window_start INTEGER NOT NULL,
2181 used INTEGER NOT NULL DEFAULT 0,
2182 PRIMARY KEY (consumer_id, counter_key, window_start)
2183 );
2184 CREATE INDEX idx_quota_counters_window ON quota_counters (window_start);
2185 INSERT INTO consumers (name, priority, created_at) VALUES ('globex', 3, 2000);
2186 INSERT INTO credentials
2187 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2188 VALUES (1, 'api_key', 'h1', NULL, 'key-1', 2001, NULL);
2189 PRAGMA user_version = 2;
2190 COMMIT;",
2191 )
2192 .unwrap();
2193 }
2194 #[test]
2195 fn v2_db_migrates_to_latest_adding_groups_in_place() {
2196 let dir = tempfile::tempdir().unwrap();
2200 let path = dir.path().join("state.db");
2201 build_v2_db(&path);
2202
2203 let store = StateStore::open(&path).unwrap();
2204 assert_eq!(store.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2205 let consumers = store.list_consumers().unwrap();
2207 assert_eq!(consumers.len(), 1);
2208 assert_eq!(consumers[0].name, "globex");
2209 assert_eq!(consumers[0].priority, Some(3));
2210 assert_eq!(consumers[0].created_at, 2000);
2211 assert_eq!(consumers[0].groups, Vec::<String>::new());
2213 let active = store.lookup_credentials_by_selector("key-1").unwrap();
2214 assert_eq!(active.len(), 1);
2215 assert_eq!(active[0].hash, "h1");
2216 store
2218 .upsert_consumer("globex", None, &["partners".to_string()])
2219 .unwrap();
2220 assert_eq!(
2221 store.lookup_consumer("globex").unwrap().unwrap().groups,
2222 vec!["partners".to_string()]
2223 );
2224 }
2225 #[test]
2226 fn malformed_groups_json_degrades_to_no_groups() {
2227 let dir = tempfile::tempdir().unwrap();
2234 let path = dir.path().join("state.db");
2235 {
2236 let store = StateStore::open(&path).unwrap();
2237 store
2238 .upsert_consumer("acme", None, &["partners".to_string()])
2239 .unwrap();
2240 }
2241 for bad in ["not-json", "null", "\"partners\"", "[1,2]", "{\"a\":1}"] {
2242 let conn = Connection::open(&path).unwrap();
2243 conn.execute("UPDATE consumers SET groups = ?1", params![bad])
2244 .unwrap();
2245 drop(conn);
2246 let store = StateStore::open(&path).unwrap();
2247 assert_eq!(
2248 store.list_consumers().unwrap()[0].groups,
2249 Vec::<String>::new(),
2250 "malformed groups {bad:?} must read back as no groups"
2251 );
2252 assert_eq!(
2253 store.lookup_consumer("acme").unwrap().unwrap().groups,
2254 Vec::<String>::new(),
2255 "malformed groups {bad:?} must read back as no groups"
2256 );
2257 }
2258 }
2259 fn dump_table(conn: &Connection, table: &str, cols: &str) -> Vec<Vec<String>> {
2268 let mut stmt = conn
2269 .prepare(&format!("SELECT {cols} FROM {table} ORDER BY rowid"))
2270 .unwrap();
2271 let n = stmt.column_count();
2272 let mut rows = stmt.query([]).unwrap();
2273 let mut out = Vec::new();
2274 while let Some(row) = rows.next().unwrap() {
2275 let mut cells = Vec::with_capacity(n);
2276 for i in 0..n {
2277 let cell = match row.get_ref(i).unwrap() {
2278 rusqlite::types::ValueRef::Null => "NULL".to_string(),
2279 rusqlite::types::ValueRef::Integer(v) => v.to_string(),
2280 rusqlite::types::ValueRef::Real(v) => v.to_string(),
2281 rusqlite::types::ValueRef::Text(v) => {
2282 format!("'{}'", String::from_utf8_lossy(v))
2283 }
2284 rusqlite::types::ValueRef::Blob(v) => format!("{v:?}"),
2285 };
2286 cells.push(cell);
2287 }
2288 out.push(cells);
2289 }
2290 out
2291 }
2292
2293 fn build_v1_db_rich(path: &Path) {
2297 let conn = Connection::open(path).unwrap();
2298 conn.execute_batch(
2299 "BEGIN;
2300 CREATE TABLE consumers (
2301 id INTEGER PRIMARY KEY AUTOINCREMENT,
2302 name TEXT NOT NULL UNIQUE,
2303 priority INTEGER,
2304 created_at INTEGER NOT NULL
2305 );
2306 CREATE TABLE credentials (
2307 id INTEGER PRIMARY KEY AUTOINCREMENT,
2308 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2309 kind TEXT NOT NULL CHECK (kind IN ('api_key', 'jwt', 'mtls')),
2310 hash TEXT NOT NULL,
2311 salt TEXT,
2312 selector TEXT NOT NULL,
2313 created_at INTEGER NOT NULL,
2314 revoked_at INTEGER
2315 );
2316 CREATE INDEX idx_credentials_selector ON credentials (selector);
2317 CREATE TABLE quota_counters (
2318 consumer_id INTEGER NOT NULL REFERENCES consumers(id),
2319 counter_key TEXT NOT NULL,
2320 window_start INTEGER NOT NULL,
2321 used INTEGER NOT NULL DEFAULT 0,
2322 PRIMARY KEY (consumer_id, counter_key, window_start)
2323 );
2324 INSERT INTO consumers (name, priority, created_at) VALUES ('acme', 5, 1000);
2325 INSERT INTO consumers (name, priority, created_at) VALUES ('globex', NULL, 1001);
2326 INSERT INTO credentials
2327 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2328 VALUES (1, 'api_key', 'h1', NULL, 'key-1', 1001, NULL);
2329 INSERT INTO credentials
2330 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2331 VALUES (1, 'jwt', 'h2', 's2', 'key-2', 1002, 5555);
2332 INSERT INTO credentials
2333 (consumer_id, kind, hash, salt, selector, created_at, revoked_at)
2334 VALUES (2, 'mtls', 'h3', 's3', 'key-3', 1003, NULL);
2335 INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2336 VALUES (1, 'rpm', 3600, 42);
2337 INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2338 VALUES (1, 'rpm', 7200, 7);
2339 INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2340 VALUES (1, 'rpd', 86400, 900);
2341 INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2342 VALUES (2, 'rpm', 3600, 1);
2343 INSERT INTO quota_counters (consumer_id, counter_key, window_start, used)
2344 VALUES (2, 'monthly', 2678400, 123456);
2345 PRAGMA user_version = 1;
2346 COMMIT;",
2347 )
2348 .unwrap();
2349 }
2350
2351 fn all_backups(dir: &Path) -> Vec<String> {
2352 std::fs::read_dir(dir)
2353 .unwrap()
2354 .filter_map(|e| {
2355 let name = e.unwrap().file_name().to_string_lossy().into_owned();
2356 name.contains(".bak-").then_some(name)
2357 })
2358 .collect()
2359 }
2360 #[test]
2361 fn migration_preserves_every_row_and_the_hot_path_still_works() {
2362 let dir = tempfile::tempdir().unwrap();
2368 let path = dir.path().join("state.db");
2369 build_v1_db_rich(&path);
2370 let before = {
2371 let conn = Connection::open(&path).unwrap();
2372 (
2373 dump_table(&conn, "consumers", "id, name, priority, created_at"),
2374 dump_table(&conn, "credentials", "*"),
2375 dump_table(&conn, "quota_counters", "*"),
2376 )
2377 };
2378
2379 let store = StateStore::open(&path).unwrap();
2380 assert_eq!(store.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2381 {
2382 let conn = store.conn.lock().expect("store connection poisoned");
2383 assert_eq!(
2384 dump_table(&conn, "consumers", "id, name, priority, created_at"),
2385 before.0
2386 );
2387 assert_eq!(
2390 dump_table(
2391 &conn,
2392 "credentials",
2393 "id, consumer_id, kind, hash, salt, selector, created_at, revoked_at"
2394 ),
2395 before.1
2396 );
2397 assert_eq!(dump_table(&conn, "quota_counters", "*"), before.2);
2398 let groups = dump_table(&conn, "consumers", "groups");
2401 assert_eq!(groups, vec![vec!["'[]'".to_string()]; 2]);
2402 let sources = dump_table(&conn, "credentials", "source_ref");
2407 assert_eq!(sources, vec![vec!["NULL".to_string()]; 3]);
2408 }
2409
2410 let consumers = store.list_consumers().unwrap();
2412 assert_eq!(consumers.len(), 2);
2413 let globex = store.lookup_consumer("globex").unwrap().unwrap();
2414 assert_eq!(globex.priority, None);
2415 assert_eq!(globex.created_at, 1001);
2416
2417 assert_eq!(
2420 store.lookup_credentials_by_selector("key-1").unwrap().len(),
2421 1
2422 );
2423 assert_eq!(
2424 store.lookup_credentials_by_selector("key-3").unwrap().len(),
2425 1
2426 );
2427 assert!(
2428 store
2429 .lookup_credentials_by_selector("key-2")
2430 .unwrap()
2431 .is_empty(),
2432 "revoked credential must stay revoked after migration"
2433 );
2434
2435 assert_eq!(store.get_quota(1, "rpm", 3600).unwrap(), 42);
2437 assert_eq!(store.get_quota(1, "rpm", 7200).unwrap(), 7);
2438 assert_eq!(store.get_quota(1, "rpd", 86400).unwrap(), 900);
2439 assert_eq!(store.get_quota(2, "rpm", 3600).unwrap(), 1);
2440 assert_eq!(store.get_quota(2, "monthly", 2678400).unwrap(), 123456);
2441
2442 let reads_before = store.disk_reads();
2444 let entry = store.lookup_credential("key-1").unwrap().unwrap();
2445 assert_eq!(entry.len(), 1);
2446 assert_eq!(store.disk_reads(), reads_before + 1);
2447 for _ in 0..3 {
2448 let again = store.lookup_credential("key-1").unwrap().unwrap();
2449 assert_eq!(again.len(), 1);
2450 }
2451 assert_eq!(
2452 store.disk_reads(),
2453 reads_before + 1,
2454 "warm cache must be disk-free post-migration"
2455 );
2456 }
2457
2458 #[test]
2459 fn rapid_reopen_and_two_live_handles_on_one_migrated_file() {
2460 let dir = tempfile::tempdir().unwrap();
2465 let path = dir.path().join("state.db");
2466 build_v1_db(&path);
2467
2468 let a = StateStore::open(&path).unwrap();
2469 assert_eq!(a.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2470 drop(a); let b = StateStore::open(&path).unwrap();
2472 assert_eq!(b.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2473 assert_eq!(b.get_quota(1, "rpm", 3600).unwrap(), 42);
2474
2475 let c = StateStore::open(&path).unwrap();
2477 c.upsert_consumer("globex", Some(9), &[]).unwrap();
2478 c.add_credential(1, CredentialKind::ApiKey, "hx".into(), None, "key-x".into())
2479 .unwrap();
2480 let names: Vec<String> = b
2482 .list_consumers()
2483 .unwrap()
2484 .into_iter()
2485 .map(|r| r.name)
2486 .collect();
2487 assert!(names.contains(&"globex".to_string()), "names: {names:?}");
2488 assert_eq!(b.lookup_credentials_by_selector("key-x").unwrap().len(), 1);
2489 let check: String = c
2491 .conn
2492 .lock()
2493 .unwrap()
2494 .query_row("PRAGMA integrity_check", [], |r| r.get(0))
2495 .unwrap();
2496 assert_eq!(check, "ok");
2497 }
2498
2499 #[test]
2500 fn version_zero_database_with_foreign_tables_migrates_without_harming_them() {
2501 let dir = tempfile::tempdir().unwrap();
2507 let path = dir.path().join("state.db");
2508 {
2509 let conn = Connection::open(&path).unwrap();
2510 conn.execute_batch(
2511 "CREATE TABLE legacy_widgets (
2512 id INTEGER PRIMARY KEY,
2513 label TEXT NOT NULL
2514 );
2515 INSERT INTO legacy_widgets (label) VALUES ('keep-me');",
2516 )
2517 .unwrap();
2518 }
2519
2520 let store = StateStore::open(&path).unwrap();
2521 assert_eq!(store.schema_version().unwrap(), LATEST_SCHEMA_VERSION);
2522 let label: String = store
2524 .conn
2525 .lock()
2526 .unwrap()
2527 .query_row("SELECT label FROM legacy_widgets WHERE id = 1", [], |r| {
2528 r.get(0)
2529 })
2530 .unwrap();
2531 assert_eq!(label, "keep-me");
2532 store.upsert_consumer("acme", None, &[]).unwrap();
2534 assert_eq!(store.list_consumers().unwrap().len(), 1);
2535 assert!(
2537 all_backups(dir.path()).is_empty(),
2538 "version-0 open must not take a backup"
2539 );
2540 }
2541
2542 #[test]
2543 fn backup_names_are_unique_within_the_same_second() {
2544 let path = Path::new("/data/state.db");
2550 let t0 = std::time::UNIX_EPOCH + std::time::Duration::from_millis(1_700_000_000_123);
2551 let a = backup_file_name(path, 1, t0);
2552 let b = backup_file_name(path, 1, t0 + std::time::Duration::from_millis(1));
2553 assert_eq!(a, "state.db.bak-1-1700000000-123");
2554 assert_eq!(b, "state.db.bak-1-1700000000-124");
2555 assert_ne!(a, b, "same-second backups must not collide");
2556 }
2557
2558 #[test]
2559 fn post_migration_pragmas_stay_configured() {
2560 let dir = tempfile::tempdir().unwrap();
2564 let path = dir.path().join("state.db");
2565 build_v1_db(&path);
2566 let store = StateStore::open(&path).unwrap();
2567 let conn = store.conn.lock().expect("store connection poisoned");
2568 let mode: String = conn
2569 .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2570 .unwrap();
2571 assert_eq!(mode, "wal");
2572 let fk: i64 = conn
2573 .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
2574 .unwrap();
2575 assert_eq!(fk, 1, "foreign_keys must be ON post-migration");
2576 let timeout_ms: i64 = conn
2577 .query_row("PRAGMA busy_timeout", [], |r| r.get(0))
2578 .unwrap();
2579 assert_eq!(timeout_ms, 5000, "busy_timeout must be 5 s post-migration");
2580 }
2581}