tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definitions

⎇ e044d475167b0b210a79df049541c22d61edd4e2 crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs Rust · 760 lines · 26.2 KB History Who touched what raw

feat(tahuantinsuyu): persistir flex de los splitters entre sesiones e044d4751 · 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    // Settings (key/value libre — layout, last-opened chart, etc.)
424    // -----------------------------------------------------------------
425
426    /// Lee un valor de la tabla `settings`. `None` si no existe.
427    pub fn get_setting(&self, key: &str) -> StoreResult<Option<String>> {
428        let conn = self.conn.lock().unwrap();
429        let val = conn
430            .query_row(
431                "SELECT value FROM settings WHERE key = ?1",
432                params![key],
433                |row| row.get::<_, String>(0),
434            )
435            .optional()?;
436        Ok(val)
437    }
438
439    /// Upsert un setting. El valor es texto libre — para JSON, el caller
440    /// serializa antes de llamar.
441    pub fn set_setting(&self, key: &str, value: &str) -> StoreResult<()> {
442        let conn = self.conn.lock().unwrap();
443        conn.execute(
444            "INSERT INTO settings (key, value) VALUES (?1, ?2) \
445             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
446            params![key, value],
447        )?;
448        Ok(())
449    }
450
451    // -----------------------------------------------------------------
452    // Recursive descent: charts under a group/contact (para thumbnails)
453    // -----------------------------------------------------------------
454
455    /// Devuelve todas las cartas que descienden de un Group (incluyendo
456    /// los Contacts de sub-groups recursivamente).
457    pub fn charts_under_group(&self, root: GroupId) -> StoreResult<Vec<Chart>> {
458        let conn = self.conn.lock().unwrap();
459        // CTE recursivo para listar todos los descendientes del group.
460        let mut stmt = conn.prepare(
461            "WITH RECURSIVE descendants(id) AS ( \
462                 SELECT ?1 \
463                 UNION ALL \
464                 SELECT g.id FROM groups g JOIN descendants d ON g.parent_id = d.id \
465             ) \
466             SELECT c.id, c.contact_id, c.kind, c.label, c.birth_data_json, c.config_json, \
467                    c.related_chart_id, c.created_at_ms \
468             FROM charts c \
469             JOIN contacts ct ON ct.id = c.contact_id \
470             WHERE ct.group_id IN descendants \
471             ORDER BY c.created_at_ms ASC",
472        )?;
473        let rows = stmt.query_map(params![root.to_string()], row_to_chart)?;
474        let mut out = Vec::new();
475        for r in rows {
476            out.push(r??);
477        }
478        Ok(out)
479    }
480}
481
482// =====================================================================
483// SQL schema
484// =====================================================================
485
486const MIGRATION_V1: &str = r#"
487PRAGMA foreign_keys = ON;
488
489CREATE TABLE IF NOT EXISTS groups (
490    id              TEXT PRIMARY KEY,
491    parent_id       TEXT,
492    name            TEXT NOT NULL,
493    description     TEXT,
494    created_at_ms   INTEGER NOT NULL,
495    sort_order      INTEGER NOT NULL DEFAULT 0,
496    FOREIGN KEY(parent_id) REFERENCES groups(id) ON DELETE CASCADE
497);
498CREATE INDEX IF NOT EXISTS idx_groups_parent ON groups(parent_id);
499
500CREATE TABLE IF NOT EXISTS contacts (
501    id              TEXT PRIMARY KEY,
502    group_id        TEXT,
503    name            TEXT NOT NULL,
504    notes           TEXT,
505    created_at_ms   INTEGER NOT NULL,
506    FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE SET NULL
507);
508CREATE INDEX IF NOT EXISTS idx_contacts_group ON contacts(group_id);
509
510CREATE TABLE IF NOT EXISTS charts (
511    id                  TEXT PRIMARY KEY,
512    contact_id          TEXT NOT NULL,
513    kind                TEXT NOT NULL,
514    label               TEXT NOT NULL,
515    birth_data_json     TEXT NOT NULL,
516    config_json         TEXT NOT NULL,
517    related_chart_id    TEXT,
518    created_at_ms       INTEGER NOT NULL,
519    FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
520    FOREIGN KEY(related_chart_id) REFERENCES charts(id) ON DELETE SET NULL
521);
522CREATE INDEX IF NOT EXISTS idx_charts_contact ON charts(contact_id);
523
524CREATE TABLE IF NOT EXISTS module_state (
525    chart_id    TEXT NOT NULL,
526    module_id   TEXT NOT NULL,
527    enabled     INTEGER NOT NULL DEFAULT 0,
528    config_json TEXT NOT NULL DEFAULT '{}',
529    PRIMARY KEY(chart_id, module_id),
530    FOREIGN KEY(chart_id) REFERENCES charts(id) ON DELETE CASCADE
531);
532
533CREATE TABLE IF NOT EXISTS settings (
534    key   TEXT PRIMARY KEY,
535    value TEXT NOT NULL
536);
537"#;
538
539// =====================================================================
540// Row decoders
541// =====================================================================
542
543fn row_to_group(row: &rusqlite::Row<'_>) -> rusqlite::Result<Group> {
544    let id_str: String = row.get(0)?;
545    let parent_id_str: Option<String> = row.get(1)?;
546    Ok(Group {
547        id: id_str
548            .parse()
549            .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
550        parent_id: match parent_id_str {
551            Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
552                rusqlite::Error::ToSqlConversionFailure(Box::new(e))
553            })?),
554            None => None,
555        },
556        name: row.get(2)?,
557        description: row.get(3)?,
558        created_at_ms: row.get(4)?,
559        sort_order: row.get(5)?,
560    })
561}
562
563fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
564    let id_str: String = row.get(0)?;
565    let group_str: Option<String> = row.get(1)?;
566    Ok(Contact {
567        id: id_str
568            .parse()
569            .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
570        group_id: match group_str {
571            Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
572                rusqlite::Error::ToSqlConversionFailure(Box::new(e))
573            })?),
574            None => None,
575        },
576        name: row.get(2)?,
577        notes: row.get(3)?,
578        created_at_ms: row.get(4)?,
579    })
580}
581
582fn row_to_chart(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreResult<Chart>> {
583    // Doble-Result porque hay deserialización JSON adentro que rusqlite no
584    // sabe modelar. El caller la aplana.
585    let id_str: String = row.get(0)?;
586    let contact_str: String = row.get(1)?;
587    let kind_json: String = row.get(2)?;
588    let label: String = row.get(3)?;
589    let bd_json: String = row.get(4)?;
590    let cfg_json: String = row.get(5)?;
591    let related_str: Option<String> = row.get(6)?;
592    let created_at_ms: i64 = row.get(7)?;
593
594    Ok((|| -> StoreResult<Chart> {
595        Ok(Chart {
596            id: id_str.parse()?,
597            contact_id: contact_str.parse()?,
598            kind: serde_json::from_str(&kind_json)?,
599            label,
600            birth_data: serde_json::from_str(&bd_json)?,
601            config: serde_json::from_str(&cfg_json)?,
602            related_chart_id: match related_str {
603                Some(s) => Some(s.parse()?),
604                None => None,
605            },
606            created_at_ms,
607        })
608    })())
609}
610
611fn now_ms() -> i64 {
612    SystemTime::now()
613        .duration_since(UNIX_EPOCH)
614        .map(|d| d.as_millis() as i64)
615        .unwrap_or(0)
616}
617
618// =====================================================================
619// Tests
620// =====================================================================
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use tahuantinsuyu_model::{ModuleState, StoredBirthData, StoredChartConfig};
626
627    #[test]
628    fn open_and_migrate() {
629        let s = Store::in_memory().unwrap();
630        let groups = s.list_groups(None).unwrap();
631        assert!(groups.is_empty());
632    }
633
634    #[test]
635    fn module_state_roundtrip() {
636        let s = Store::in_memory().unwrap();
637        let g = s.create_group(None, "Familia", None).unwrap();
638        let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
639        let chart = s
640            .create_chart(
641                c.id,
642                ChartKind::Natal,
643                "Natal",
644                &StoredBirthData {
645                    year: 1987,
646                    month: 3,
647                    day: 14,
648                    hour: 5,
649                    minute: 22,
650                    second: 0.0,
651                    tz_offset_minutes: -240,
652                    latitude_deg: 10.4806,
653                    longitude_deg: -66.9036,
654                    altitude_m: 900.0,
655                    time_certainty: Default::default(),
656                    subject_name: None,
657                    birthplace_label: None,
658                },
659                &StoredChartConfig::default(),
660                None,
661            )
662            .unwrap();
663
664        // Persistir dos módulos con configs distintos.
665        let state1 = ModuleState {
666            chart_id: chart.id,
667            module_id: "transit".into(),
668            enabled: true,
669            config: serde_json::json!({}),
670        };
671        let state2 = ModuleState {
672            chart_id: chart.id,
673            module_id: "progression".into(),
674            enabled: false,
675            config: serde_json::json!({ "target_age_years": 42.5 }),
676        };
677        s.upsert_module_state(&state1).unwrap();
678        s.upsert_module_state(&state2).unwrap();
679
680        let loaded = s.list_module_states(chart.id).unwrap();
681        assert_eq!(loaded.len(), 2);
682        let by_id: std::collections::HashMap<_, _> =
683            loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
684        assert_eq!(by_id["transit"].enabled, true);
685        assert_eq!(by_id["progression"].enabled, false);
686        assert_eq!(
687            by_id["progression"]
688                .config
689                .get("target_age_years")
690                .and_then(|v| v.as_f64()),
691            Some(42.5)
692        );
693
694        // Upsert: cambiar enabled de transit a false.
695        let state1_off = ModuleState {
696            chart_id: chart.id,
697            module_id: "transit".into(),
698            enabled: false,
699            config: serde_json::json!({}),
700        };
701        s.upsert_module_state(&state1_off).unwrap();
702        let loaded = s.list_module_states(chart.id).unwrap();
703        let by_id: std::collections::HashMap<_, _> =
704            loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
705        assert_eq!(by_id["transit"].enabled, false);
706    }
707
708    #[test]
709    fn settings_upsert_and_read() {
710        let s = Store::in_memory().unwrap();
711        assert_eq!(s.get_setting("layout.outer").unwrap(), None);
712        s.set_setting("layout.outer", "4.0,1.0").unwrap();
713        assert_eq!(
714            s.get_setting("layout.outer").unwrap().as_deref(),
715            Some("4.0,1.0")
716        );
717        // Upsert — el segundo set sobreescribe.
718        s.set_setting("layout.outer", "3.5,1.5").unwrap();
719        assert_eq!(
720            s.get_setting("layout.outer").unwrap().as_deref(),
721            Some("3.5,1.5")
722        );
723    }
724
725    #[test]
726    fn full_hierarchy_roundtrip() {
727        let s = Store::in_memory().unwrap();
728        let g = s.create_group(None, "Familia", None).unwrap();
729        let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
730        let chart = s
731            .create_chart(
732                c.id,
733                ChartKind::Natal,
734                "Natal",
735                &StoredBirthData {
736                    year: 1987,
737                    month: 3,
738                    day: 14,
739                    hour: 5,
740                    minute: 22,
741                    second: 0.0,
742                    tz_offset_minutes: -240,
743                    latitude_deg: 10.4806,
744                    longitude_deg: -66.9036,
745                    altitude_m: 900.0,
746                    time_certainty: Default::default(),
747                    subject_name: Some("Sergio".into()),
748                    birthplace_label: Some("Caracas".into()),
749                },
750                &StoredChartConfig::default(),
751                None,
752            )
753            .unwrap();
754        assert_eq!(s.get_chart(chart.id).unwrap().label, "Natal");
755
756        let under = s.charts_under_group(g.id).unwrap();
757        assert_eq!(under.len(), 1);
758        assert_eq!(under[0].id, chart.id);
759    }
760}