Skip to main content

imagegen_bridge_runtime/
sqlite_presets.rs

1//! Durable `SQLite` storage for reusable request presets.
2
3use std::path::Path;
4
5use imagegen_bridge_core::{
6    BridgeError, ErrorCode, ImagePreset, ImagePresetCreate, ImagePresetTemplate, ImagePresetWrite,
7    validate_preset_name, validate_preset_write,
8};
9use tokio_rusqlite::{Connection, params, rusqlite::OptionalExtension as _};
10
11type PresetRow = (String, Option<String>, String, u64, u64);
12
13/// Durable named preset store sharing the configured server state database.
14pub struct SqlitePresetStore {
15    connection: Connection,
16}
17
18impl std::fmt::Debug for SqlitePresetStore {
19    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        formatter
21            .debug_struct("SqlitePresetStore")
22            .finish_non_exhaustive()
23    }
24}
25
26impl SqlitePresetStore {
27    /// Opens the preset store and applies its idempotent schema.
28    pub async fn open(path: &Path) -> Result<Self, BridgeError> {
29        match tokio::fs::symlink_metadata(path).await {
30            Ok(metadata) if metadata.file_type().is_file() => {}
31            Ok(_) => {
32                return Err(preset_storage_error(
33                    "preset database must be a regular file",
34                ));
35            }
36            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
37            Err(_) => return Err(preset_storage_error("could not inspect preset database")),
38        }
39        let connection = Connection::open(path)
40            .await
41            .map_err(|_| preset_storage_error("could not open preset database"))?;
42        connection
43            .call(|connection| {
44                connection.execute_batch(
45                    "PRAGMA journal_mode=WAL;
46                     PRAGMA synchronous=FULL;
47                     PRAGMA busy_timeout=5000;
48                     CREATE TABLE IF NOT EXISTS image_presets (
49                       name TEXT PRIMARY KEY,
50                       description TEXT,
51                       template_json TEXT NOT NULL,
52                       created_at INTEGER NOT NULL,
53                       updated_at INTEGER NOT NULL
54                     );
55                     CREATE INDEX IF NOT EXISTS image_presets_updated_idx
56                       ON image_presets(updated_at DESC, name ASC);",
57                )?;
58                Ok::<_, tokio_rusqlite::rusqlite::Error>(())
59            })
60            .await
61            .map_err(|_| preset_storage_error("could not initialize preset database"))?;
62        Ok(Self { connection })
63    }
64
65    /// Creates a preset and returns a conflict when the name already exists.
66    pub async fn create(
67        &self,
68        create: ImagePresetCreate,
69        now: u64,
70    ) -> Result<ImagePreset, BridgeError> {
71        validate_preset_name(&create.name)?;
72        let write = ImagePresetWrite {
73            description: create.description,
74            template: create.template,
75        };
76        validate_preset_write(&write)?;
77        let template = encode_template(&write.template)?;
78        let name = create.name;
79        let description = write.description;
80        let stored_name = name.clone();
81        let stored_description = description.clone();
82        let inserted = self
83            .connection
84            .call(move |connection| {
85                connection.execute(
86                    "INSERT OR IGNORE INTO image_presets(name,description,template_json,created_at,updated_at)
87                     VALUES (?1,?2,?3,?4,?4)",
88                    params![stored_name, stored_description, template, now],
89                )
90            })
91            .await
92            .map_err(|_| preset_storage_error("could not create preset"))?;
93        if inserted == 0 {
94            return Err(BridgeError::new(
95                ErrorCode::IdempotencyConflict,
96                "a preset with this name already exists",
97            )
98            .with_detail("field", "name")
99            .with_detail("resource", "preset"));
100        }
101        Ok(ImagePreset {
102            name,
103            description,
104            template: write.template,
105            created: now,
106            updated: now,
107        })
108    }
109
110    /// Returns one preset by exact name.
111    pub async fn get(&self, name: &str) -> Result<ImagePreset, BridgeError> {
112        validate_preset_name(name)?;
113        let name = name.to_owned();
114        let row = self
115            .connection
116            .call(move |connection| {
117                connection
118                    .query_row(
119                        "SELECT name,description,template_json,created_at,updated_at
120                         FROM image_presets WHERE name=?1",
121                        [name],
122                        |row| {
123                            Ok((
124                                row.get(0)?,
125                                row.get(1)?,
126                                row.get(2)?,
127                                row.get(3)?,
128                                row.get(4)?,
129                            ))
130                        },
131                    )
132                    .optional()
133            })
134            .await
135            .map_err(|_| preset_storage_error("could not read preset"))?
136            .ok_or_else(preset_not_found)?;
137        decode_row(row)
138    }
139
140    /// Lists presets in stable name order after an optional exclusive cursor.
141    pub async fn list(
142        &self,
143        after: Option<String>,
144        limit: usize,
145    ) -> Result<Vec<ImagePreset>, BridgeError> {
146        let rows = self
147            .connection
148            .call(move |connection| {
149                let mut statement = connection.prepare(
150                    "SELECT name,description,template_json,created_at,updated_at
151                     FROM image_presets
152                     WHERE (?1 IS NULL OR name > ?1)
153                     ORDER BY name ASC LIMIT ?2",
154                )?;
155                statement
156                    .query_map(params![after, limit], |row| {
157                        Ok((
158                            row.get(0)?,
159                            row.get(1)?,
160                            row.get(2)?,
161                            row.get(3)?,
162                            row.get(4)?,
163                        ))
164                    })?
165                    .collect::<Result<Vec<PresetRow>, _>>()
166            })
167            .await
168            .map_err(|_| preset_storage_error("could not list presets"))?;
169        rows.into_iter().map(decode_row).collect()
170    }
171
172    /// Replaces an existing preset without changing its name or creation time.
173    pub async fn replace(
174        &self,
175        name: &str,
176        write: ImagePresetWrite,
177        now: u64,
178    ) -> Result<ImagePreset, BridgeError> {
179        validate_preset_name(name)?;
180        validate_preset_write(&write)?;
181        let template = encode_template(&write.template)?;
182        let stored_name = name.to_owned();
183        let stored_description = write.description.clone();
184        let updated = self
185            .connection
186            .call(move |connection| {
187                connection.execute(
188                    "UPDATE image_presets
189                     SET description=?2,template_json=?3,updated_at=?4 WHERE name=?1",
190                    params![stored_name, stored_description, template, now],
191                )
192            })
193            .await
194            .map_err(|_| preset_storage_error("could not update preset"))?;
195        if updated == 0 {
196            return Err(preset_not_found());
197        }
198        self.get(name).await
199    }
200
201    /// Deletes a preset, returning not-found when it does not exist.
202    pub async fn delete(&self, name: &str) -> Result<(), BridgeError> {
203        validate_preset_name(name)?;
204        let name = name.to_owned();
205        let deleted = self
206            .connection
207            .call(move |connection| {
208                connection.execute("DELETE FROM image_presets WHERE name=?1", [name])
209            })
210            .await
211            .map_err(|_| preset_storage_error("could not delete preset"))?;
212        if deleted == 0 {
213            return Err(preset_not_found());
214        }
215        Ok(())
216    }
217}
218
219fn encode_template(template: &ImagePresetTemplate) -> Result<String, BridgeError> {
220    serde_json::to_string(template).map_err(|_| preset_storage_error("could not encode preset"))
221}
222
223fn decode_row(row: PresetRow) -> Result<ImagePreset, BridgeError> {
224    let (name, description, template, created, updated) = row;
225    let template = serde_json::from_str(&template)
226        .map_err(|_| preset_storage_error("stored preset is invalid"))?;
227    Ok(ImagePreset {
228        name,
229        description,
230        template,
231        created,
232        updated,
233    })
234}
235
236fn preset_not_found() -> BridgeError {
237    BridgeError::new(ErrorCode::InvalidRequest, "preset was not found")
238        .with_detail("resource", "preset")
239}
240
241fn preset_storage_error(message: &'static str) -> BridgeError {
242    BridgeError::new(ErrorCode::Internal, message).with_detail("stage", "preset_storage")
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[tokio::test]
250    async fn preset_crud_is_durable_and_conflict_safe() -> Result<(), Box<dyn std::error::Error>> {
251        let directory = tempfile::tempdir()?;
252        let path = directory.path().join("state.sqlite3");
253        let store = SqlitePresetStore::open(&path).await?;
254        let created = store
255            .create(
256                ImagePresetCreate {
257                    name: "portrait".to_owned(),
258                    description: Some("Editorial portrait".to_owned()),
259                    template: ImagePresetTemplate::default(),
260                },
261                10,
262            )
263            .await?;
264        assert_eq!(created.name, "portrait");
265        assert_eq!(store.list(None, 10).await?.len(), 1);
266        let conflict = store
267            .create(
268                ImagePresetCreate {
269                    name: "portrait".to_owned(),
270                    description: None,
271                    template: ImagePresetTemplate::default(),
272                },
273                11,
274            )
275            .await;
276        assert!(matches!(
277            conflict,
278            Err(ref error) if error.code == ErrorCode::IdempotencyConflict
279        ));
280        let updated = store
281            .replace(
282                "portrait",
283                ImagePresetWrite {
284                    description: Some("Updated".to_owned()),
285                    template: ImagePresetTemplate::default(),
286                },
287                12,
288            )
289            .await?;
290        assert_eq!(updated.created, 10);
291        assert_eq!(updated.updated, 12);
292        store.delete("portrait").await?;
293        assert!(store.get("portrait").await.is_err());
294        Ok(())
295    }
296}