tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definiciones

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs Rust · 714 líneas · 24.6 KB Historia Quién tocó qué crudo

feat(tahuantinsuyu): fase 17 — filtros de aspectos + editor + cleanup + labels 0ae622550 · sergio ·

1//! `tahuantinsuyu-store` — persistencia SQLite del estudio astrológico.
2//!
3//! Una sola conexión `rusqlite` envuelta en `Arc<Mutex>` para que la app
4//! GPUI la comparta entre threads sin pelearse con el ownership. La
5//! migración inicial corre la primera vez que se abre un archivo nuevo
6//! (idempotente vía `CREATE TABLE IF NOT EXISTS`).
7//!
8//! Patrón inspirado en `yahweh_provider_sqlite::SqliteDataProvider` pero
9//! con dominio propio (no extiende el `DataProvider` agnóstico — esa
10//! integración viene en `tahuantinsuyu-tree` que envuelve este store
11//! detrás del trait de yahweh).
12
13#![forbid(unsafe_code)]
14#![warn(rust_2018_idioms)]
15
16use std::path::Path;
17use std::sync::{Arc, Mutex};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use rusqlite::{Connection, OptionalExtension, params};
21use thiserror::Error;
22
23use tahuantinsuyu_model::{
24    Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, ModuleState, StoredBirthData,
25    StoredChartConfig,
26};
27
28const SCHEMA_VERSION: i32 = 1;
29
30#[derive(Debug, Error)]
31pub enum StoreError {
32    #[error("sqlite: {0}")]
33    Sqlite(#[from] rusqlite::Error),
34    #[error("json: {0}")]
35    Json(#[from] serde_json::Error),
36    #[error("schema downgrade: db is at v{found}, code expects v{expected}")]
37    SchemaDowngrade { found: i32, expected: i32 },
38    #[error("ulid decode: {0}")]
39    UlidDecode(#[from] ulid::DecodeError),
40    #[error("model invariant: {0}")]
41    Model(#[from] tahuantinsuyu_model::ModelError),
42    #[error("not found: {0}")]
43    NotFound(String),
44}
45
46pub type StoreResult<T> = Result<T, StoreError>;
47
48/// Store backed by a single SQLite file.
49///
50/// Clone-able: comparte la misma conexión bajo el mutex. Útil para que
51/// distintos widgets (tree, panel, canvas) compartan una vista
52/// consistente sin pasar `&mut` por todos lados.
53#[derive(Clone)]
54pub struct Store {
55    conn: Arc<Mutex<Connection>>,
56}
57
58impl Store {
59    /// Abre (o crea) un archivo SQLite y corre las migraciones.
60    pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
61        let conn = Connection::open(path)?;
62        let store = Self {
63            conn: Arc::new(Mutex::new(conn)),
64        };
65        store.migrate()?;
66        Ok(store)
67    }
68
69    /// Variante in-memory para tests.
70    pub fn in_memory() -> StoreResult<Self> {
71        let conn = Connection::open_in_memory()?;
72        let store = Self {
73            conn: Arc::new(Mutex::new(conn)),
74        };
75        store.migrate()?;
76        Ok(store)
77    }
78
79    fn migrate(&self) -> StoreResult<()> {
80        let conn = self.conn.lock().unwrap();
81        conn.execute_batch(MIGRATION_V1)?;
82
83        let found: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
84        if found > SCHEMA_VERSION {
85            return Err(StoreError::SchemaDowngrade {
86                found,
87                expected: SCHEMA_VERSION,
88            });
89        }
90        if found < SCHEMA_VERSION {
91            conn.execute(&format!("PRAGMA user_version = {}", SCHEMA_VERSION), [])?;
92        }
93        Ok(())
94    }
95
96    // -----------------------------------------------------------------
97    // Groups
98    // -----------------------------------------------------------------
99
100    pub fn create_group(
101        &self,
102        parent_id: Option<GroupId>,
103        name: &str,
104        description: Option<&str>,
105    ) -> StoreResult<Group> {
106        let group = Group {
107            id: GroupId::new(),
108            parent_id,
109            name: name.into(),
110            description: description.map(String::from),
111            created_at_ms: now_ms(),
112            sort_order: 0,
113        };
114        let conn = self.conn.lock().unwrap();
115        conn.execute(
116            "INSERT INTO groups (id, parent_id, name, description, created_at_ms, sort_order) \
117             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
118            params![
119                group.id.to_string(),
120                group.parent_id.map(|g| g.to_string()),
121                group.name,
122                group.description,
123                group.created_at_ms,
124                group.sort_order,
125            ],
126        )?;
127        Ok(group)
128    }
129
130    pub fn list_groups(&self, parent_id: Option<GroupId>) -> StoreResult<Vec<Group>> {
131        let conn = self.conn.lock().unwrap();
132        let mut stmt = conn.prepare(
133            "SELECT id, parent_id, name, description, created_at_ms, sort_order \
134             FROM groups WHERE parent_id IS ?1 \
135             ORDER BY sort_order ASC, name COLLATE NOCASE ASC",
136        )?;
137        let parent_str = parent_id.map(|g| g.to_string());
138        let rows = stmt.query_map(params![parent_str], row_to_group)?;
139        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
140    }
141
142    pub fn delete_group(&self, id: GroupId) -> StoreResult<()> {
143        let conn = self.conn.lock().unwrap();
144        conn.execute("DELETE FROM groups WHERE id = ?1", params![id.to_string()])?;
145        Ok(())
146    }
147
148    pub fn rename_group(&self, id: GroupId, name: &str) -> StoreResult<()> {
149        let conn = self.conn.lock().unwrap();
150        conn.execute(
151            "UPDATE groups SET name = ?2 WHERE id = ?1",
152            params![id.to_string(), name],
153        )?;
154        Ok(())
155    }
156
157    /// Cambia el `parent_id` de un Group. Pasar `None` para mover a raíz.
158    /// **No** valida ciclos — el caller debe garantizar que el nuevo
159    /// padre no sea descendiente del que mueve (sino la DB queda con un
160    /// ciclo que el list_groups no rompe pero hace al CTE infinito).
161    pub fn move_group(&self, id: GroupId, new_parent: Option<GroupId>) -> StoreResult<()> {
162        let conn = self.conn.lock().unwrap();
163        conn.execute(
164            "UPDATE groups SET parent_id = ?2 WHERE id = ?1",
165            params![id.to_string(), new_parent.map(|g| g.to_string())],
166        )?;
167        Ok(())
168    }
169
170    // -----------------------------------------------------------------
171    // Contacts
172    // -----------------------------------------------------------------
173
174    pub fn create_contact(
175        &self,
176        group_id: Option<GroupId>,
177        name: &str,
178        notes: Option<&str>,
179    ) -> StoreResult<Contact> {
180        let c = Contact {
181            id: ContactId::new(),
182            group_id,
183            name: name.into(),
184            notes: notes.map(String::from),
185            created_at_ms: now_ms(),
186        };
187        let conn = self.conn.lock().unwrap();
188        conn.execute(
189            "INSERT INTO contacts (id, group_id, name, notes, created_at_ms) \
190             VALUES (?1, ?2, ?3, ?4, ?5)",
191            params![
192                c.id.to_string(),
193                c.group_id.map(|g| g.to_string()),
194                c.name,
195                c.notes,
196                c.created_at_ms,
197            ],
198        )?;
199        Ok(c)
200    }
201
202    pub fn list_contacts(&self, group_id: Option<GroupId>) -> StoreResult<Vec<Contact>> {
203        let conn = self.conn.lock().unwrap();
204        let mut stmt = conn.prepare(
205            "SELECT id, group_id, name, notes, created_at_ms \
206             FROM contacts WHERE group_id IS ?1 \
207             ORDER BY name COLLATE NOCASE ASC",
208        )?;
209        let g = group_id.map(|g| g.to_string());
210        let rows = stmt.query_map(params![g], row_to_contact)?;
211        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
212    }
213
214    pub fn delete_contact(&self, id: ContactId) -> StoreResult<()> {
215        let conn = self.conn.lock().unwrap();
216        conn.execute("DELETE FROM contacts WHERE id = ?1", params![id.to_string()])?;
217        Ok(())
218    }
219
220    pub fn rename_contact(&self, id: ContactId, name: &str) -> StoreResult<()> {
221        let conn = self.conn.lock().unwrap();
222        conn.execute(
223            "UPDATE contacts SET name = ?2 WHERE id = ?1",
224            params![id.to_string(), name],
225        )?;
226        Ok(())
227    }
228
229    pub fn move_contact(&self, id: ContactId, new_group: Option<GroupId>) -> StoreResult<()> {
230        let conn = self.conn.lock().unwrap();
231        conn.execute(
232            "UPDATE contacts SET group_id = ?2 WHERE id = ?1",
233            params![id.to_string(), new_group.map(|g| g.to_string())],
234        )?;
235        Ok(())
236    }
237
238    // -----------------------------------------------------------------
239    // Charts
240    // -----------------------------------------------------------------
241
242    pub fn create_chart(
243        &self,
244        contact_id: ContactId,
245        kind: ChartKind,
246        label: &str,
247        birth: &StoredBirthData,
248        config: &StoredChartConfig,
249        related_chart_id: Option<ChartId>,
250    ) -> StoreResult<Chart> {
251        let chart = Chart {
252            id: ChartId::new(),
253            contact_id,
254            kind,
255            label: label.into(),
256            birth_data: birth.clone(),
257            config: config.clone(),
258            related_chart_id,
259            created_at_ms: now_ms(),
260        };
261        chart.validate()?;
262        let conn = self.conn.lock().unwrap();
263        conn.execute(
264            "INSERT INTO charts \
265             (id, contact_id, kind, label, birth_data_json, config_json, \
266              related_chart_id, created_at_ms) \
267             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
268            params![
269                chart.id.to_string(),
270                chart.contact_id.to_string(),
271                serde_json::to_string(&chart.kind)?,
272                chart.label,
273                serde_json::to_string(&chart.birth_data)?,
274                serde_json::to_string(&chart.config)?,
275                chart.related_chart_id.map(|c| c.to_string()),
276                chart.created_at_ms,
277            ],
278        )?;
279        Ok(chart)
280    }
281
282    pub fn list_charts(&self, contact_id: ContactId) -> StoreResult<Vec<Chart>> {
283        let conn = self.conn.lock().unwrap();
284        let mut stmt = conn.prepare(
285            "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
286                    related_chart_id, created_at_ms \
287             FROM charts WHERE contact_id = ?1 \
288             ORDER BY created_at_ms ASC",
289        )?;
290        let rows = stmt.query_map(params![contact_id.to_string()], row_to_chart)?;
291        rows.collect::<Result<Vec<_>, _>>()
292            .map_err(StoreError::from)
293            .and_then(|v| v.into_iter().collect::<StoreResult<Vec<_>>>())
294    }
295
296    /// Lista todas las cartas del DB ordenadas por label (case-insensitive).
297    /// Pensado para pickers / selectores cross-contact (ej. elegir un
298    /// partner de sinastría desde cualquier contacto).
299    pub fn list_all_charts(&self) -> StoreResult<Vec<Chart>> {
300        let conn = self.conn.lock().unwrap();
301        let mut stmt = conn.prepare(
302            "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
303                    related_chart_id, created_at_ms \
304             FROM charts ORDER BY label COLLATE NOCASE ASC",
305        )?;
306        let rows = stmt.query_map([], row_to_chart)?;
307        let mut out = Vec::new();
308        for r in rows {
309            out.push(r??);
310        }
311        Ok(out)
312    }
313
314    pub fn get_chart(&self, id: ChartId) -> StoreResult<Chart> {
315        let conn = self.conn.lock().unwrap();
316        let mut stmt = conn.prepare(
317            "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
318                    related_chart_id, created_at_ms \
319             FROM charts WHERE id = ?1",
320        )?;
321        let chart = stmt
322            .query_row(params![id.to_string()], row_to_chart)
323            .optional()?;
324        match chart {
325            Some(Ok(c)) => Ok(c),
326            Some(Err(e)) => Err(e),
327            None => Err(StoreError::NotFound(format!("chart {}", id))),
328        }
329    }
330
331    pub fn delete_chart(&self, id: ChartId) -> StoreResult<()> {
332        let conn = self.conn.lock().unwrap();
333        conn.execute("DELETE FROM charts WHERE id = ?1", params![id.to_string()])?;
334        Ok(())
335    }
336
337    pub fn rename_chart(&self, id: ChartId, label: &str) -> StoreResult<()> {
338        let conn = self.conn.lock().unwrap();
339        conn.execute(
340            "UPDATE charts SET label = ?2 WHERE id = ?1",
341            params![id.to_string(), label],
342        )?;
343        Ok(())
344    }
345
346    /// Reemplaza label + birth_data + config de una carta existente,
347    /// preservando id / contact_id / related_chart_id / created_at_ms y
348    /// el `module_state` asociado (no se borra). Usado por el editor de
349    /// rectificación natal.
350    pub fn update_chart(
351        &self,
352        id: ChartId,
353        label: &str,
354        birth: &StoredBirthData,
355        config: &StoredChartConfig,
356    ) -> StoreResult<()> {
357        let conn = self.conn.lock().unwrap();
358        conn.execute(
359            "UPDATE charts SET label = ?2, birth_data_json = ?3, config_json = ?4 \
360             WHERE id = ?1",
361            params![
362                id.to_string(),
363                label,
364                serde_json::to_string(birth)?,
365                serde_json::to_string(config)?,
366            ],
367        )?;
368        Ok(())
369    }
370
371    // -----------------------------------------------------------------
372    // Module state
373    // -----------------------------------------------------------------
374
375    pub fn upsert_module_state(&self, state: &ModuleState) -> StoreResult<()> {
376        let conn = self.conn.lock().unwrap();
377        conn.execute(
378            "INSERT INTO module_state (chart_id, module_id, enabled, config_json) \
379             VALUES (?1, ?2, ?3, ?4) \
380             ON CONFLICT(chart_id, module_id) DO UPDATE SET \
381                 enabled = excluded.enabled, \
382                 config_json = excluded.config_json",
383            params![
384                state.chart_id.to_string(),
385                state.module_id,
386                state.enabled as i32,
387                state.config.to_string(),
388            ],
389        )?;
390        Ok(())
391    }
392
393    pub fn list_module_states(&self, chart_id: ChartId) -> StoreResult<Vec<ModuleState>> {
394        let conn = self.conn.lock().unwrap();
395        let mut stmt = conn.prepare(
396            "SELECT chart_id, module_id, enabled, config_json \
397             FROM module_state WHERE chart_id = ?1",
398        )?;
399        let rows = stmt.query_map(params![chart_id.to_string()], |row| {
400            Ok((
401                row.get::<_, String>(0)?,
402                row.get::<_, String>(1)?,
403                row.get::<_, i32>(2)?,
404                row.get::<_, String>(3)?,
405            ))
406        })?;
407        let mut out = Vec::new();
408        for r in rows {
409            let (chart_str, module_id, enabled, config_str) = r?;
410            out.push(ModuleState {
411                chart_id: chart_str
412                    .parse()
413                    .map_err(|e: ulid::DecodeError| StoreError::UlidDecode(e))?,
414                module_id,
415                enabled: enabled != 0,
416                config: serde_json::from_str(&config_str).unwrap_or(serde_json::Value::Null),
417            });
418        }
419        Ok(out)
420    }
421
422    // -----------------------------------------------------------------
423    // Recursive descent: charts under a group/contact (para thumbnails)
424    // -----------------------------------------------------------------
425
426    /// Devuelve todas las cartas que descienden de un Group (incluyendo
427    /// los Contacts de sub-groups recursivamente).
428    pub fn charts_under_group(&self, root: GroupId) -> StoreResult<Vec<Chart>> {
429        let conn = self.conn.lock().unwrap();
430        // CTE recursivo para listar todos los descendientes del group.
431        let mut stmt = conn.prepare(
432            "WITH RECURSIVE descendants(id) AS ( \
433                 SELECT ?1 \
434                 UNION ALL \
435                 SELECT g.id FROM groups g JOIN descendants d ON g.parent_id = d.id \
436             ) \
437             SELECT c.id, c.contact_id, c.kind, c.label, c.birth_data_json, c.config_json, \
438                    c.related_chart_id, c.created_at_ms \
439             FROM charts c \
440             JOIN contacts ct ON ct.id = c.contact_id \
441             WHERE ct.group_id IN descendants \
442             ORDER BY c.created_at_ms ASC",
443        )?;
444        let rows = stmt.query_map(params![root.to_string()], row_to_chart)?;
445        let mut out = Vec::new();
446        for r in rows {
447            out.push(r??);
448        }
449        Ok(out)
450    }
451}
452
453// =====================================================================
454// SQL schema
455// =====================================================================
456
457const MIGRATION_V1: &str = r#"
458PRAGMA foreign_keys = ON;
459
460CREATE TABLE IF NOT EXISTS groups (
461    id              TEXT PRIMARY KEY,
462    parent_id       TEXT,
463    name            TEXT NOT NULL,
464    description     TEXT,
465    created_at_ms   INTEGER NOT NULL,
466    sort_order      INTEGER NOT NULL DEFAULT 0,
467    FOREIGN KEY(parent_id) REFERENCES groups(id) ON DELETE CASCADE
468);
469CREATE INDEX IF NOT EXISTS idx_groups_parent ON groups(parent_id);
470
471CREATE TABLE IF NOT EXISTS contacts (
472    id              TEXT PRIMARY KEY,
473    group_id        TEXT,
474    name            TEXT NOT NULL,
475    notes           TEXT,
476    created_at_ms   INTEGER NOT NULL,
477    FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE SET NULL
478);
479CREATE INDEX IF NOT EXISTS idx_contacts_group ON contacts(group_id);
480
481CREATE TABLE IF NOT EXISTS charts (
482    id                  TEXT PRIMARY KEY,
483    contact_id          TEXT NOT NULL,
484    kind                TEXT NOT NULL,
485    label               TEXT NOT NULL,
486    birth_data_json     TEXT NOT NULL,
487    config_json         TEXT NOT NULL,
488    related_chart_id    TEXT,
489    created_at_ms       INTEGER NOT NULL,
490    FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
491    FOREIGN KEY(related_chart_id) REFERENCES charts(id) ON DELETE SET NULL
492);
493CREATE INDEX IF NOT EXISTS idx_charts_contact ON charts(contact_id);
494
495CREATE TABLE IF NOT EXISTS module_state (
496    chart_id    TEXT NOT NULL,
497    module_id   TEXT NOT NULL,
498    enabled     INTEGER NOT NULL DEFAULT 0,
499    config_json TEXT NOT NULL DEFAULT '{}',
500    PRIMARY KEY(chart_id, module_id),
501    FOREIGN KEY(chart_id) REFERENCES charts(id) ON DELETE CASCADE
502);
503
504CREATE TABLE IF NOT EXISTS settings (
505    key   TEXT PRIMARY KEY,
506    value TEXT NOT NULL
507);
508"#;
509
510// =====================================================================
511// Row decoders
512// =====================================================================
513
514fn row_to_group(row: &rusqlite::Row<'_>) -> rusqlite::Result<Group> {
515    let id_str: String = row.get(0)?;
516    let parent_id_str: Option<String> = row.get(1)?;
517    Ok(Group {
518        id: id_str
519            .parse()
520            .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
521        parent_id: match parent_id_str {
522            Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
523                rusqlite::Error::ToSqlConversionFailure(Box::new(e))
524            })?),
525            None => None,
526        },
527        name: row.get(2)?,
528        description: row.get(3)?,
529        created_at_ms: row.get(4)?,
530        sort_order: row.get(5)?,
531    })
532}
533
534fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
535    let id_str: String = row.get(0)?;
536    let group_str: Option<String> = row.get(1)?;
537    Ok(Contact {
538        id: id_str
539            .parse()
540            .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
541        group_id: match group_str {
542            Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
543                rusqlite::Error::ToSqlConversionFailure(Box::new(e))
544            })?),
545            None => None,
546        },
547        name: row.get(2)?,
548        notes: row.get(3)?,
549        created_at_ms: row.get(4)?,
550    })
551}
552
553fn row_to_chart(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreResult<Chart>> {
554    // Doble-Result porque hay deserialización JSON adentro que rusqlite no
555    // sabe modelar. El caller la aplana.
556    let id_str: String = row.get(0)?;
557    let contact_str: String = row.get(1)?;
558    let kind_json: String = row.get(2)?;
559    let label: String = row.get(3)?;
560    let bd_json: String = row.get(4)?;
561    let cfg_json: String = row.get(5)?;
562    let related_str: Option<String> = row.get(6)?;
563    let created_at_ms: i64 = row.get(7)?;
564
565    Ok((|| -> StoreResult<Chart> {
566        Ok(Chart {
567            id: id_str.parse()?,
568            contact_id: contact_str.parse()?,
569            kind: serde_json::from_str(&kind_json)?,
570            label,
571            birth_data: serde_json::from_str(&bd_json)?,
572            config: serde_json::from_str(&cfg_json)?,
573            related_chart_id: match related_str {
574                Some(s) => Some(s.parse()?),
575                None => None,
576            },
577            created_at_ms,
578        })
579    })())
580}
581
582fn now_ms() -> i64 {
583    SystemTime::now()
584        .duration_since(UNIX_EPOCH)
585        .map(|d| d.as_millis() as i64)
586        .unwrap_or(0)
587}
588
589// =====================================================================
590// Tests
591// =====================================================================
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use tahuantinsuyu_model::{ModuleState, StoredBirthData, StoredChartConfig};
597
598    #[test]
599    fn open_and_migrate() {
600        let s = Store::in_memory().unwrap();
601        let groups = s.list_groups(None).unwrap();
602        assert!(groups.is_empty());
603    }
604
605    #[test]
606    fn module_state_roundtrip() {
607        let s = Store::in_memory().unwrap();
608        let g = s.create_group(None, "Familia", None).unwrap();
609        let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
610        let chart = s
611            .create_chart(
612                c.id,
613                ChartKind::Natal,
614                "Natal",
615                &StoredBirthData {
616                    year: 1987,
617                    month: 3,
618                    day: 14,
619                    hour: 5,
620                    minute: 22,
621                    second: 0.0,
622                    tz_offset_minutes: -240,
623                    latitude_deg: 10.4806,
624                    longitude_deg: -66.9036,
625                    altitude_m: 900.0,
626                    time_certainty: Default::default(),
627                    subject_name: None,
628                    birthplace_label: None,
629                },
630                &StoredChartConfig::default(),
631                None,
632            )
633            .unwrap();
634
635        // Persistir dos módulos con configs distintos.
636        let state1 = ModuleState {
637            chart_id: chart.id,
638            module_id: "transit".into(),
639            enabled: true,
640            config: serde_json::json!({}),
641        };
642        let state2 = ModuleState {
643            chart_id: chart.id,
644            module_id: "progression".into(),
645            enabled: false,
646            config: serde_json::json!({ "target_age_years": 42.5 }),
647        };
648        s.upsert_module_state(&state1).unwrap();
649        s.upsert_module_state(&state2).unwrap();
650
651        let loaded = s.list_module_states(chart.id).unwrap();
652        assert_eq!(loaded.len(), 2);
653        let by_id: std::collections::HashMap<_, _> =
654            loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
655        assert_eq!(by_id["transit"].enabled, true);
656        assert_eq!(by_id["progression"].enabled, false);
657        assert_eq!(
658            by_id["progression"]
659                .config
660                .get("target_age_years")
661                .and_then(|v| v.as_f64()),
662            Some(42.5)
663        );
664
665        // Upsert: cambiar enabled de transit a false.
666        let state1_off = ModuleState {
667            chart_id: chart.id,
668            module_id: "transit".into(),
669            enabled: false,
670            config: serde_json::json!({}),
671        };
672        s.upsert_module_state(&state1_off).unwrap();
673        let loaded = s.list_module_states(chart.id).unwrap();
674        let by_id: std::collections::HashMap<_, _> =
675            loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
676        assert_eq!(by_id["transit"].enabled, false);
677    }
678
679    #[test]
680    fn full_hierarchy_roundtrip() {
681        let s = Store::in_memory().unwrap();
682        let g = s.create_group(None, "Familia", None).unwrap();
683        let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
684        let chart = s
685            .create_chart(
686                c.id,
687                ChartKind::Natal,
688                "Natal",
689                &StoredBirthData {
690                    year: 1987,
691                    month: 3,
692                    day: 14,
693                    hour: 5,
694                    minute: 22,
695                    second: 0.0,
696                    tz_offset_minutes: -240,
697                    latitude_deg: 10.4806,
698                    longitude_deg: -66.9036,
699                    altitude_m: 900.0,
700                    time_certainty: Default::default(),
701                    subject_name: Some("Sergio".into()),
702                    birthplace_label: Some("Caracas".into()),
703                },
704                &StoredChartConfig::default(),
705                None,
706            )
707            .unwrap();
708        assert_eq!(s.get_chart(chart.id).unwrap().label, "Natal");
709
710        let under = s.charts_under_group(g.id).unwrap();
711        assert_eq!(under.len(), 1);
712        assert_eq!(under[0].id, chart.id);
713    }
714}