Skip to main content

cedros_data/store/
defaults.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use sqlx::{Postgres, Row, Transaction};
4
5use crate::defaults::{
6    default_monetization_payload, default_navigation_payload, default_page_payload,
7    default_page_templates, default_site_settings_payload, default_tipping_payload,
8    DEFAULT_SITE_NAME,
9};
10use crate::error::{CedrosDataError, Result};
11use crate::models::{
12    Collection, CollectionMode, DefaultPageTemplate, EntryRecord, QueryEntriesRequest,
13    RegisterCollectionRequest, RegisterSiteRequest, SiteBootstrapReport, UpsertEntryRequest,
14};
15
16use super::{load_site_if_configured_with, map_collection_row, CedrosData};
17
18const DEFAULT_COLLECTION_NAMES: [&str; 3] = ["pages", "navigation", "site_settings"];
19
20impl CedrosData {
21    pub async fn ensure_site_exists(&self) -> Result<()> {
22        if load_site_if_configured_with(self.pool()).await?.is_none() {
23            self.register_site(RegisterSiteRequest {
24                display_name: DEFAULT_SITE_NAME.to_string(),
25                metadata: serde_json::json!({ "seeded": true }),
26            })
27            .await?;
28        }
29        Ok(())
30    }
31
32    pub async fn bootstrap_defaults(&self) -> Result<SiteBootstrapReport> {
33        self.ensure_site_exists().await?;
34        let mut tx = self.pool().begin().await?;
35        let report = self.bootstrap_defaults_in_transaction(&mut tx).await?;
36        tx.commit().await?;
37        Ok(report)
38    }
39
40    pub async fn list_default_page_entries(&self) -> Result<Vec<EntryRecord>> {
41        self.query_entries(default_page_entries_request()).await
42    }
43
44    pub async fn upsert_default_page_entry(
45        &self,
46        page_key: &str,
47        payload: serde_json::Value,
48    ) -> Result<EntryRecord> {
49        if !payload.is_object() {
50            return Err(CedrosDataError::InvalidRequest(
51                "page payload must be a JSON object".to_string(),
52            ));
53        }
54
55        self.upsert_entry(UpsertEntryRequest {
56            collection_name: "pages".to_string(),
57            entry_key: page_key.to_string(),
58            payload,
59        })
60        .await
61    }
62
63    pub(super) async fn bootstrap_defaults_in_transaction(
64        &self,
65        tx: &mut Transaction<'_, Postgres>,
66    ) -> Result<SiteBootstrapReport> {
67        let templates = default_page_templates();
68        let mut collections = load_default_collections_with(&mut **tx).await?;
69        let collections_seeded = seed_collections(self, tx, &mut collections).await?;
70        let existing_entries = load_default_entry_keys_with(&mut **tx).await?;
71
72        let pages_seeded = seed_pages(
73            self,
74            tx,
75            require_collection(&collections, "pages")?,
76            existing_entries.get("pages"),
77            &templates,
78        )
79        .await?;
80        let nav_seeded = seed_navigation(
81            self,
82            tx,
83            require_collection(&collections, "navigation")?,
84            existing_entries.get("navigation"),
85            &templates,
86        )
87        .await?;
88        let settings_seeded = seed_site_settings(
89            self,
90            tx,
91            require_collection(&collections, "site_settings")?,
92            existing_entries.get("site_settings"),
93        )
94        .await?;
95        let tipping_seeded = seed_settings_entry(
96            self,
97            tx,
98            require_collection(&collections, "site_settings")?,
99            existing_entries.get("site_settings"),
100            "tipping",
101            default_tipping_payload(),
102        )
103        .await?;
104        let monetization_seeded = seed_settings_entry(
105            self,
106            tx,
107            require_collection(&collections, "site_settings")?,
108            existing_entries.get("site_settings"),
109            "monetization",
110            default_monetization_payload(),
111        )
112        .await?;
113
114        Ok(SiteBootstrapReport {
115            collections_seeded,
116            entries_seeded: pages_seeded
117                + nav_seeded
118                + settings_seeded
119                + tipping_seeded
120                + monetization_seeded,
121        })
122    }
123}
124
125async fn seed_collections(
126    store: &CedrosData,
127    tx: &mut Transaction<'_, Postgres>,
128    collections: &mut BTreeMap<String, Collection>,
129) -> Result<usize> {
130    let mut seeded = 0;
131
132    for collection_name in DEFAULT_COLLECTION_NAMES {
133        if collections.contains_key(collection_name) {
134            continue;
135        }
136        let collection = store
137            .register_collection_in_transaction(
138                tx,
139                RegisterCollectionRequest {
140                    collection_name: collection_name.to_string(),
141                    mode: CollectionMode::Jsonb,
142                    table_name: None,
143                    strict_contract: None,
144                },
145                true,
146            )
147            .await?;
148        collections.insert(collection_name.to_string(), collection);
149        seeded += 1;
150    }
151
152    Ok(seeded)
153}
154
155async fn seed_pages(
156    store: &CedrosData,
157    tx: &mut Transaction<'_, Postgres>,
158    collection: &Collection,
159    existing_keys: Option<&BTreeSet<String>>,
160    templates: &[DefaultPageTemplate],
161) -> Result<usize> {
162    let mut seeded = 0;
163    for template in templates {
164        if has_entry_key(existing_keys, &template.key) {
165            continue;
166        }
167        store
168            .upsert_entry_for_collection_in_transaction(
169                tx,
170                collection,
171                &UpsertEntryRequest {
172                    collection_name: "pages".to_string(),
173                    entry_key: template.key.clone(),
174                    payload: default_page_payload(template),
175                },
176            )
177            .await?;
178        seeded += 1;
179    }
180    Ok(seeded)
181}
182
183async fn seed_navigation(
184    store: &CedrosData,
185    tx: &mut Transaction<'_, Postgres>,
186    collection: &Collection,
187    existing_keys: Option<&BTreeSet<String>>,
188    templates: &[DefaultPageTemplate],
189) -> Result<usize> {
190    if has_entry_key(existing_keys, "main") {
191        return Ok(0);
192    }
193
194    store
195        .upsert_entry_for_collection_in_transaction(
196            tx,
197            collection,
198            &UpsertEntryRequest {
199                collection_name: "navigation".to_string(),
200                entry_key: "main".to_string(),
201                payload: default_navigation_payload(templates),
202            },
203        )
204        .await?;
205    Ok(1)
206}
207
208async fn seed_site_settings(
209    store: &CedrosData,
210    tx: &mut Transaction<'_, Postgres>,
211    collection: &Collection,
212    existing_keys: Option<&BTreeSet<String>>,
213) -> Result<usize> {
214    if has_entry_key(existing_keys, "global") {
215        return Ok(0);
216    }
217
218    store
219        .upsert_entry_for_collection_in_transaction(
220            tx,
221            collection,
222            &UpsertEntryRequest {
223                collection_name: "site_settings".to_string(),
224                entry_key: "global".to_string(),
225                payload: default_site_settings_payload(),
226            },
227        )
228        .await?;
229    Ok(1)
230}
231
232async fn seed_settings_entry(
233    store: &CedrosData,
234    tx: &mut Transaction<'_, Postgres>,
235    collection: &Collection,
236    existing_keys: Option<&BTreeSet<String>>,
237    entry_key: &str,
238    payload: serde_json::Value,
239) -> Result<usize> {
240    if has_entry_key(existing_keys, entry_key) {
241        return Ok(0);
242    }
243
244    store
245        .upsert_entry_for_collection_in_transaction(
246            tx,
247            collection,
248            &UpsertEntryRequest {
249                collection_name: "site_settings".to_string(),
250                entry_key: entry_key.to_string(),
251                payload,
252            },
253        )
254        .await?;
255    Ok(1)
256}
257
258fn require_collection<'a>(
259    collections: &'a BTreeMap<String, Collection>,
260    collection_name: &str,
261) -> Result<&'a Collection> {
262    collections
263        .get(collection_name)
264        .ok_or_else(|| CedrosDataError::CollectionNotFound(collection_name.to_string()))
265}
266
267fn has_entry_key(existing_keys: Option<&BTreeSet<String>>, entry_key: &str) -> bool {
268    existing_keys.is_some_and(|keys| keys.contains(entry_key))
269}
270
271async fn load_default_collections_with<'e, E>(executor: E) -> Result<BTreeMap<String, Collection>>
272where
273    E: sqlx::Executor<'e, Database = Postgres>,
274{
275    let rows = sqlx::query(
276        "SELECT collection_name, mode, table_name, strict_contract
277         FROM collections
278         WHERE collection_name = ANY($1)",
279    )
280    .bind(default_collection_names())
281    .fetch_all(executor)
282    .await?;
283
284    rows.into_iter()
285        .map(map_collection_row)
286        .map(|result| result.map(|collection| (collection.collection_name.clone(), collection)))
287        .collect()
288}
289
290async fn load_default_entry_keys_with<'e, E>(
291    executor: E,
292) -> Result<BTreeMap<String, BTreeSet<String>>>
293where
294    E: sqlx::Executor<'e, Database = Postgres>,
295{
296    let rows = sqlx::query(
297        "SELECT collection_name, entry_key
298         FROM entries
299         WHERE collection_name = ANY($1)",
300    )
301    .bind(default_collection_names())
302    .fetch_all(executor)
303    .await?;
304
305    let mut entries = BTreeMap::new();
306    for row in rows {
307        let collection_name = row.get::<String, _>("collection_name");
308        let entry_key = row.get::<String, _>("entry_key");
309        entries
310            .entry(collection_name)
311            .or_insert_with(BTreeSet::new)
312            .insert(entry_key);
313    }
314    Ok(entries)
315}
316
317fn default_collection_names() -> Vec<String> {
318    DEFAULT_COLLECTION_NAMES
319        .iter()
320        .map(|name| (*name).to_string())
321        .collect()
322}
323
324fn default_page_entries_request() -> QueryEntriesRequest {
325    let entry_keys = default_page_templates()
326        .into_iter()
327        .map(|template| template.key)
328        .collect::<Vec<String>>();
329
330    QueryEntriesRequest {
331        collection_name: "pages".to_string(),
332        limit: entry_keys.len() as i64,
333        entry_keys,
334        contains: None,
335        offset: 0,
336        visitor_id: None,
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::default_page_entries_request;
343
344    #[test]
345    fn default_page_entries_request_targets_known_default_page_keys() {
346        let request = default_page_entries_request();
347        assert_eq!(request.collection_name, "pages");
348        assert_eq!(request.limit, request.entry_keys.len() as i64);
349        assert!(request.entry_keys.contains(&"home".to_string()));
350        assert!(request.entry_keys.contains(&"not-found".to_string()));
351    }
352}