cratestack_client_store_sqlite/
lib.rs1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Mutex;
4
5use chrono::{DateTime, Utc};
6use cratestack_client_rust::{
7 ClientError, ClientStateStore, PersistedClientState, RequestJournalEntry,
8};
9use rusqlite::{Connection, OptionalExtension, params};
10
11const SQLITE_SCHEMA_VERSION: u32 = 1;
12
13pub struct SqliteStateStore {
14 connection: Mutex<Connection>,
15 path: PathBuf,
16}
17
18impl SqliteStateStore {
19 pub fn open(path: impl Into<PathBuf>) -> Result<Self, ClientError> {
20 let path = path.into();
21 if let Some(parent) = path.parent() {
22 fs::create_dir_all(parent).map_err(|error| {
23 ClientError::State(format!(
24 "failed to create SQLite state directory {}: {error}",
25 parent.display()
26 ))
27 })?;
28 }
29 let connection = Connection::open(&path).map_err(|error| {
30 ClientError::State(format!(
31 "failed to open SQLite state store {}: {error}",
32 path.display()
33 ))
34 })?;
35 bootstrap(&connection, SQLITE_SCHEMA_VERSION)?;
36
37 Ok(Self {
38 connection: Mutex::new(connection),
39 path,
40 })
41 }
42
43 pub fn path(&self) -> &Path {
44 &self.path
45 }
46}
47
48impl ClientStateStore for SqliteStateStore {
49 fn load(&self) -> Result<PersistedClientState, ClientError> {
50 let connection = self.connection.lock().map_err(|error| {
51 ClientError::State(format!("failed to lock SQLite state store: {error}"))
52 })?;
53 load_state(&connection)
54 }
55
56 fn save(&self, state: &PersistedClientState) -> Result<(), ClientError> {
57 let mut connection = self.connection.lock().map_err(|error| {
58 ClientError::State(format!("failed to lock SQLite state store: {error}"))
59 })?;
60 save_state(&mut connection, state)
61 }
62
63 fn append_request_journal(&self, entry: &RequestJournalEntry) -> Result<(), ClientError> {
64 let mut connection = self.connection.lock().map_err(|error| {
65 ClientError::State(format!("failed to lock SQLite state store: {error}"))
66 })?;
67 let transaction = connection.transaction().map_err(sqlite_error)?;
68 transaction
69 .execute(
70 "INSERT INTO request_journal (method, path, status_code, content_type, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5)",
71 params![
72 &entry.method,
73 &entry.path,
74 entry.status_code,
75 &entry.content_type,
76 entry.recorded_at.to_rfc3339(),
77 ],
78 )
79 .map_err(sqlite_error)?;
80 transaction
81 .execute(
82 "UPDATE state_meta SET state_version = state_version + 1, updated_at = ?1 WHERE singleton = 1",
83 params![Utc::now().to_rfc3339()],
84 )
85 .map_err(sqlite_error)?;
86 transaction.commit().map_err(sqlite_error)
87 }
88}
89
90fn bootstrap(connection: &Connection, schema_version: u32) -> Result<(), ClientError> {
91 connection
92 .execute_batch(
93 "
94 CREATE TABLE IF NOT EXISTS state_meta (
95 singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
96 schema_version INTEGER NOT NULL,
97 state_version INTEGER NOT NULL,
98 updated_at TEXT NOT NULL
99 );
100
101 CREATE TABLE IF NOT EXISTS request_journal (
102 seq INTEGER PRIMARY KEY AUTOINCREMENT,
103 method TEXT NOT NULL,
104 path TEXT NOT NULL,
105 status_code INTEGER NOT NULL,
106 content_type TEXT,
107 recorded_at TEXT NOT NULL
108 );
109
110 CREATE INDEX IF NOT EXISTS request_journal_recorded_at_idx
111 ON request_journal(recorded_at);
112 ",
113 )
114 .map_err(sqlite_error)?;
115 let exists = connection
116 .query_row("SELECT 1 FROM state_meta WHERE singleton = 1", [], |row| {
117 row.get::<_, i64>(0)
118 })
119 .optional()
120 .map_err(sqlite_error)?
121 .is_some();
122 if !exists {
123 connection
124 .execute(
125 "INSERT INTO state_meta (singleton, schema_version, state_version, updated_at) VALUES (1, ?1, 0, ?2)",
126 params![schema_version, Utc::now().to_rfc3339()],
127 )
128 .map_err(sqlite_error)?;
129 }
130 Ok(())
131}
132
133fn load_state(connection: &Connection) -> Result<PersistedClientState, ClientError> {
134 let (schema_version, state_version) = connection
135 .query_row(
136 "SELECT schema_version, state_version FROM state_meta WHERE singleton = 1",
137 [],
138 |row| Ok((row.get::<_, u32>(0)?, row.get::<_, u64>(1)?)),
139 )
140 .map_err(sqlite_error)?;
141
142 let mut statement = connection
143 .prepare(
144 "SELECT method, path, status_code, content_type, recorded_at FROM request_journal ORDER BY seq ASC",
145 )
146 .map_err(sqlite_error)?;
147 let rows = statement
148 .query_map([], |row| {
149 let recorded_at = row.get::<_, String>(4)?;
150 let recorded_at = DateTime::parse_from_rfc3339(&recorded_at)
151 .map(|value| value.with_timezone(&Utc))
152 .map_err(|error| {
153 rusqlite::Error::FromSqlConversionFailure(
154 4,
155 rusqlite::types::Type::Text,
156 Box::new(error),
157 )
158 })?;
159 Ok(RequestJournalEntry {
160 method: row.get(0)?,
161 path: row.get(1)?,
162 status_code: row.get(2)?,
163 content_type: row.get(3)?,
164 recorded_at,
165 })
166 })
167 .map_err(sqlite_error)?;
168 let request_journal = rows.collect::<Result<Vec<_>, _>>().map_err(sqlite_error)?;
169
170 Ok(PersistedClientState {
171 schema_version,
172 state_version,
173 request_journal,
174 })
175}
176
177fn save_state(
178 connection: &mut Connection,
179 state: &PersistedClientState,
180) -> Result<(), ClientError> {
181 let transaction = connection.transaction().map_err(sqlite_error)?;
182 transaction
183 .execute("DELETE FROM request_journal", [])
184 .map_err(sqlite_error)?;
185 transaction
186 .execute(
187 "UPDATE state_meta SET schema_version = ?1, state_version = ?2, updated_at = ?3 WHERE singleton = 1",
188 params![state.schema_version, state.state_version, Utc::now().to_rfc3339()],
189 )
190 .map_err(sqlite_error)?;
191
192 {
193 let mut statement = transaction
194 .prepare(
195 "INSERT INTO request_journal (method, path, status_code, content_type, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5)",
196 )
197 .map_err(sqlite_error)?;
198 for entry in &state.request_journal {
199 statement
200 .execute(params![
201 &entry.method,
202 &entry.path,
203 entry.status_code,
204 &entry.content_type,
205 entry.recorded_at.to_rfc3339(),
206 ])
207 .map_err(sqlite_error)?;
208 }
209 }
210
211 transaction.commit().map_err(sqlite_error)
212}
213
214fn sqlite_error(error: rusqlite::Error) -> ClientError {
215 ClientError::State(format!("SQLite state store error: {error}"))
216}
217
218#[cfg(test)]
219mod tests {
220 use std::path::{Path, PathBuf};
221 use std::time::{SystemTime, UNIX_EPOCH};
222
223 use chrono::Utc;
224 use cratestack_client_rust::{ClientStateStore, RequestJournalEntry};
225
226 use super::SqliteStateStore;
227
228 #[test]
229 fn bootstrap_loads_default_state() {
230 let path = project_tmp_path("bootstrap");
231 cleanup(&path);
232
233 let store = SqliteStateStore::open(&path).expect("store should open");
234 let state = store.load().expect("state should load");
235
236 assert_eq!(state.schema_version, 1);
237 assert_eq!(state.state_version, 0);
238 assert!(state.request_journal.is_empty());
239
240 cleanup(&path);
241 }
242
243 #[test]
244 fn append_round_trips_and_increments_state_version() {
245 let path = project_tmp_path("append");
246 cleanup(&path);
247
248 let store = SqliteStateStore::open(&path).expect("store should open");
249 store
250 .append_request_journal(&RequestJournalEntry {
251 method: "POST".to_owned(),
252 path: "/$procs/getFeed".to_owned(),
253 status_code: 200,
254 content_type: Some("application/cbor".to_owned()),
255 recorded_at: Utc::now(),
256 })
257 .expect("journal entry should append");
258
259 let state = store.load().expect("state should load");
260 assert_eq!(state.state_version, 1);
261 assert_eq!(state.request_journal.len(), 1);
262
263 cleanup(&path);
264 }
265
266 fn project_tmp_path(label: &str) -> PathBuf {
267 let suffix = SystemTime::now()
268 .duration_since(UNIX_EPOCH)
269 .expect("time should move forward")
270 .as_nanos();
271 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
272 .join("../../tmp/client-store-sqlite-tests")
273 .join(format!("{label}-{suffix}.sqlite"))
274 }
275
276 fn cleanup(path: &Path) {
277 if path.exists() {
278 std::fs::remove_file(path).expect("tmp file should be removable");
279 }
280 }
281}