Skip to main content

grit_core/
export.rs

1//! Lossless JSONL export/import of the full graph + oplog (Design
2//! Invariant 8). This format ships in v0.1 and is a permanent compatibility
3//! surface: one JSON object per line, first line is a header, every other
4//! line is `{"t": "<record type>", ...}`.
5//!
6//! Embeddings are deliberately absent: they are recomputable local state
7//! tagged with a model id (Design Invariant 5); `embedding_meta` is exported
8//! so the importing side knows what to re-embed with.
9
10use std::io::{BufRead, Write};
11use std::path::Path;
12
13use rusqlite::{Connection, params};
14use serde::{Deserialize, Serialize};
15use serde_json::Value as Json;
16
17use crate::Grit;
18use crate::error::{Error, Result};
19use crate::migrate::SCHEMA_VERSION;
20
21/// Header line of an export stream.
22#[derive(Debug, Serialize, Deserialize)]
23struct Header {
24    grit_export: u32,
25    schema_version: i64,
26}
27
28/// One data line of an export stream.
29#[derive(Debug, Serialize, Deserialize)]
30#[serde(tag = "t", rename_all = "snake_case")]
31enum Record {
32    Node {
33        id: String,
34        kind: String,
35        name: String,
36        summary: String,
37        attrs: Json,
38        group_id: String,
39        created_at: i64,
40        expired_at: Option<i64>,
41        merged_into: Option<String>,
42        hlc: String,
43    },
44    Edge {
45        id: String,
46        src: String,
47        dst: String,
48        rel: String,
49        fact: String,
50        attrs: Json,
51        group_id: String,
52        valid_at: Option<i64>,
53        invalid_at: Option<i64>,
54        created_at: i64,
55        expired_at: Option<i64>,
56        hlc: String,
57    },
58    Episode {
59        id: String,
60        source: String,
61        /// Source-kind tag; absent in pre-v3 exports.
62        #[serde(default)]
63        kind: String,
64        content: String,
65        occurred_at: i64,
66        group_id: String,
67        created_at: i64,
68        hlc: String,
69    },
70    Mention {
71        episode_id: String,
72        target_id: String,
73    },
74    EdgeInvalidation {
75        edge_id: String,
76        invalid_at: i64,
77        recorded_at: i64,
78        hlc: String,
79    },
80    NodeUpdate {
81        node_id: String,
82        field: String,
83        value: String,
84        hlc: String,
85    },
86    Purged {
87        id: String,
88        merged_into: Option<String>,
89    },
90    Oplog {
91        id: String,
92        hlc: String,
93        device_id: String,
94        op: Json,
95        applied_at: i64,
96    },
97    EmbeddingMeta {
98        model_id: String,
99        dim: i64,
100        model_version: String,
101    },
102}
103
104/// Counts of what an import inserted.
105#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
106pub struct ImportStats {
107    /// Node rows inserted.
108    pub nodes: usize,
109    /// Edge rows inserted.
110    pub edges: usize,
111    /// Episode rows inserted.
112    pub episodes: usize,
113    /// Mention rows inserted.
114    pub mentions: usize,
115    /// Oplog entries inserted.
116    pub oplog: usize,
117}
118
119impl Grit {
120    /// Stream the entire database (graph tables + oplog + embedding metadata,
121    /// not the recomputable vectors) as JSONL. The inverse of
122    /// [`import_jsonl`].
123    ///
124    /// # Example
125    /// ```no_run
126    /// # use grit_core::{Grit, Options};
127    /// # let g = Grit::open("memory.db", Options::new("laptop"))?;
128    /// let mut out = Vec::new();
129    /// g.export_jsonl(&mut out)?;
130    /// # Ok::<(), grit_core::Error>(())
131    /// ```
132    pub fn export_jsonl<W: Write>(&self, mut out: W) -> Result<()> {
133        let conn = self.read();
134        // One read transaction: all table dumps come from the same committed
135        // snapshot — a write landing mid-export cannot tear the stream.
136        let tx = conn.unchecked_transaction()?;
137        let mut line = |record: &dyn erased::Line| -> Result<()> {
138            out.write_all(record.to_json_line()?.as_bytes())?;
139            out.write_all(b"\n")?;
140            Ok(())
141        };
142        line(&Header {
143            grit_export: 1,
144            schema_version: SCHEMA_VERSION,
145        })?;
146
147        let mut stmt = tx.prepare(
148            "SELECT id, kind, name, summary, attrs, group_id, created_at, expired_at,
149                    merged_into, hlc
150             FROM nodes ORDER BY id",
151        )?;
152        let rows = stmt.query_map([], |r| {
153            Ok(Record::Node {
154                id: r.get(0)?,
155                kind: r.get(1)?,
156                name: r.get(2)?,
157                summary: r.get(3)?,
158                attrs: parse_json(r.get::<_, String>(4)?)?,
159                group_id: r.get(5)?,
160                created_at: r.get(6)?,
161                expired_at: r.get(7)?,
162                merged_into: r.get(8)?,
163                hlc: r.get(9)?,
164            })
165        })?;
166        for row in rows {
167            line(&row?)?;
168        }
169
170        let mut stmt = tx.prepare(
171            "SELECT id, src, dst, rel, fact, attrs, group_id, valid_at, invalid_at,
172                    created_at, expired_at, hlc
173             FROM edges ORDER BY id",
174        )?;
175        let rows = stmt.query_map([], |r| {
176            Ok(Record::Edge {
177                id: r.get(0)?,
178                src: r.get(1)?,
179                dst: r.get(2)?,
180                rel: r.get(3)?,
181                fact: r.get(4)?,
182                attrs: parse_json(r.get::<_, String>(5)?)?,
183                group_id: r.get(6)?,
184                valid_at: r.get(7)?,
185                invalid_at: r.get(8)?,
186                created_at: r.get(9)?,
187                expired_at: r.get(10)?,
188                hlc: r.get(11)?,
189            })
190        })?;
191        for row in rows {
192            line(&row?)?;
193        }
194
195        let mut stmt = tx.prepare(
196            "SELECT id, source, kind, content, occurred_at, group_id, created_at, hlc
197             FROM episodes ORDER BY id",
198        )?;
199        let rows = stmt.query_map([], |r| {
200            Ok(Record::Episode {
201                id: r.get(0)?,
202                source: r.get(1)?,
203                kind: r.get(2)?,
204                content: r.get(3)?,
205                occurred_at: r.get(4)?,
206                group_id: r.get(5)?,
207                created_at: r.get(6)?,
208                hlc: r.get(7)?,
209            })
210        })?;
211        for row in rows {
212            line(&row?)?;
213        }
214
215        let mut stmt = conn
216            .prepare("SELECT episode_id, target_id FROM mentions ORDER BY episode_id, target_id")?;
217        let rows = stmt.query_map([], |r| {
218            Ok(Record::Mention {
219                episode_id: r.get(0)?,
220                target_id: r.get(1)?,
221            })
222        })?;
223        for row in rows {
224            line(&row?)?;
225        }
226
227        let mut stmt = tx.prepare(
228            "SELECT edge_id, invalid_at, recorded_at, hlc FROM edge_invalidations
229             ORDER BY edge_id, invalid_at",
230        )?;
231        let rows = stmt.query_map([], |r| {
232            Ok(Record::EdgeInvalidation {
233                edge_id: r.get(0)?,
234                invalid_at: r.get(1)?,
235                recorded_at: r.get(2)?,
236                hlc: r.get(3)?,
237            })
238        })?;
239        for row in rows {
240            line(&row?)?;
241        }
242
243        let mut stmt = tx.prepare(
244            "SELECT node_id, field, value, hlc FROM node_updates
245             ORDER BY node_id, field",
246        )?;
247        let rows = stmt.query_map([], |r| {
248            Ok(Record::NodeUpdate {
249                node_id: r.get(0)?,
250                field: r.get(1)?,
251                value: r.get(2)?,
252                hlc: r.get(3)?,
253            })
254        })?;
255        for row in rows {
256            line(&row?)?;
257        }
258
259        let mut stmt = tx.prepare("SELECT id, merged_into FROM purged ORDER BY id")?;
260        let rows = stmt.query_map([], |r| {
261            Ok(Record::Purged {
262                id: r.get(0)?,
263                merged_into: r.get(1)?,
264            })
265        })?;
266        for row in rows {
267            line(&row?)?;
268        }
269
270        let mut stmt =
271            tx.prepare("SELECT id, hlc, device_id, op, applied_at FROM oplog ORDER BY seq")?;
272        let rows = stmt.query_map([], |r| {
273            Ok(Record::Oplog {
274                id: r.get(0)?,
275                hlc: r.get(1)?,
276                device_id: r.get(2)?,
277                op: parse_json(r.get::<_, String>(3)?)?,
278                applied_at: r.get(4)?,
279            })
280        })?;
281        for row in rows {
282            line(&row?)?;
283        }
284
285        let mut stmt =
286            tx.prepare("SELECT model_id, dim, model_version FROM embedding_meta WHERE id = 1")?;
287        let rows = stmt.query_map([], |r| {
288            Ok(Record::EmbeddingMeta {
289                model_id: r.get(0)?,
290                dim: r.get(1)?,
291                model_version: r.get(2)?,
292            })
293        })?;
294        for row in rows {
295            line(&row?)?;
296        }
297        Ok(())
298    }
299}
300
301/// Load a JSONL export into a **fresh** database file at `db_path` (errors if
302/// the file already contains data — imports never merge). FTS mirrors are
303/// rebuilt by the schema triggers as rows insert.
304pub fn import_jsonl(db_path: impl AsRef<Path>, input: impl BufRead) -> Result<ImportStats> {
305    crate::vecext::register_sqlite_vec();
306    let mut conn = Connection::open(db_path)?;
307    crate::configure(&conn)?;
308    crate::migrate::migrate(&mut conn)?;
309
310    let existing: i64 = conn.query_row(
311        "SELECT (SELECT COUNT(*) FROM oplog) + (SELECT COUNT(*) FROM nodes)",
312        [],
313        |r| r.get(0),
314    )?;
315    if existing > 0 {
316        return Err(Error::Import(
317            "refusing to import into a non-empty database".into(),
318        ));
319    }
320
321    let mut lines = input.lines();
322    let header_line = lines
323        .next()
324        .ok_or_else(|| Error::Import("empty import stream".into()))??;
325    let header: Header = serde_json::from_str(&header_line)
326        .map_err(|e| Error::Import(format!("bad header: {e}")))?;
327    if header.schema_version > SCHEMA_VERSION {
328        return Err(Error::SchemaTooNew {
329            found: header.schema_version,
330            supported: SCHEMA_VERSION,
331        });
332    }
333
334    let tx = conn.transaction()?;
335    let mut stats = ImportStats::default();
336    for (lineno, text) in lines.enumerate() {
337        let text = text?;
338        if text.trim().is_empty() {
339            continue;
340        }
341        let record: Record = serde_json::from_str(&text)
342            .map_err(|e| Error::Import(format!("line {}: {e}", lineno + 2)))?;
343        let check_uuid = |field: &str, value: &str| -> Result<()> {
344            uuid::Uuid::parse_str(value).map(|_| ()).map_err(|_| {
345                Error::Import(format!(
346                    "line {}: {field} is not a uuid: {value:?}",
347                    lineno + 2
348                ))
349            })
350        };
351        match record {
352            Record::Node {
353                id,
354                kind,
355                name,
356                summary,
357                attrs,
358                group_id,
359                created_at,
360                expired_at,
361                merged_into,
362                hlc,
363            } => {
364                check_uuid("id", &id)?;
365                if let Some(m) = &merged_into {
366                    check_uuid("merged_into", m)?;
367                }
368                tx.execute(
369                    "INSERT INTO nodes (id, kind, name, summary, attrs, group_id,
370                                        created_at, expired_at, merged_into, hlc)
371                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
372                    params![
373                        id,
374                        kind,
375                        name,
376                        summary,
377                        attrs.to_string(),
378                        group_id,
379                        created_at,
380                        expired_at,
381                        merged_into,
382                        hlc
383                    ],
384                )?;
385                stats.nodes += 1;
386            }
387            Record::Edge {
388                id,
389                src,
390                dst,
391                rel,
392                fact,
393                attrs,
394                group_id,
395                valid_at,
396                invalid_at,
397                created_at,
398                expired_at,
399                hlc,
400            } => {
401                check_uuid("id", &id)?;
402                check_uuid("src", &src)?;
403                check_uuid("dst", &dst)?;
404                tx.execute(
405                    "INSERT INTO edges (id, src, dst, rel, fact, attrs, group_id, valid_at,
406                                        invalid_at, created_at, expired_at, hlc)
407                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
408                    params![
409                        id,
410                        src,
411                        dst,
412                        rel,
413                        fact,
414                        attrs.to_string(),
415                        group_id,
416                        valid_at,
417                        invalid_at,
418                        created_at,
419                        expired_at,
420                        hlc
421                    ],
422                )?;
423                stats.edges += 1;
424            }
425            Record::Episode {
426                id,
427                source,
428                kind,
429                content,
430                occurred_at,
431                group_id,
432                created_at,
433                hlc,
434            } => {
435                check_uuid("id", &id)?;
436                tx.execute(
437                    "INSERT INTO episodes (id, source, kind, content, occurred_at, group_id,
438                                           created_at, hlc)
439                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
440                    params![
441                        id,
442                        source,
443                        kind,
444                        content,
445                        occurred_at,
446                        group_id,
447                        created_at,
448                        hlc
449                    ],
450                )?;
451                stats.episodes += 1;
452            }
453            Record::Mention {
454                episode_id,
455                target_id,
456            } => {
457                check_uuid("episode_id", &episode_id)?;
458                check_uuid("target_id", &target_id)?;
459                tx.execute(
460                    "INSERT INTO mentions (episode_id, target_id) VALUES (?1, ?2)",
461                    params![episode_id, target_id],
462                )?;
463                stats.mentions += 1;
464            }
465            Record::EdgeInvalidation {
466                edge_id,
467                invalid_at,
468                recorded_at,
469                hlc,
470            } => {
471                check_uuid("edge_id", &edge_id)?;
472                tx.execute(
473                    "INSERT INTO edge_invalidations (edge_id, invalid_at, recorded_at, hlc)
474                     VALUES (?1, ?2, ?3, ?4)",
475                    params![edge_id, invalid_at, recorded_at, hlc],
476                )?;
477            }
478            Record::NodeUpdate {
479                node_id,
480                field,
481                value,
482                hlc,
483            } => {
484                check_uuid("node_id", &node_id)?;
485                if !matches!(field.as_str(), "name" | "summary" | "kind" | "attrs") {
486                    return Err(Error::Import(format!(
487                        "line {}: unknown node_updates field: {field:?}",
488                        lineno + 2
489                    )));
490                }
491                tx.execute(
492                    "INSERT INTO node_updates (node_id, field, value, hlc)
493                     VALUES (?1, ?2, ?3, ?4)",
494                    params![node_id, field, value, hlc],
495                )?;
496            }
497            Record::Purged { id, merged_into } => {
498                check_uuid("id", &id)?;
499                if let Some(m) = &merged_into {
500                    check_uuid("merged_into", m)?;
501                }
502                tx.execute(
503                    "INSERT INTO purged (id, merged_into) VALUES (?1, ?2)",
504                    params![id, merged_into],
505                )?;
506            }
507            Record::Oplog {
508                id,
509                hlc,
510                device_id,
511                op,
512                applied_at,
513            } => {
514                check_uuid("id", &id)?;
515                tx.execute(
516                    "INSERT INTO oplog (id, hlc, device_id, op, applied_at)
517                     VALUES (?1, ?2, ?3, ?4, ?5)",
518                    params![id, hlc, device_id, op.to_string(), applied_at],
519                )?;
520                stats.oplog += 1;
521            }
522            Record::EmbeddingMeta {
523                model_id,
524                dim,
525                model_version,
526            } => {
527                tx.execute(
528                    "INSERT INTO embedding_meta (id, model_id, dim, model_version)
529                     VALUES (1, ?1, ?2, ?3)",
530                    params![model_id, dim, model_version],
531                )?;
532            }
533        }
534    }
535    tx.commit()?;
536    Ok(stats)
537}
538
539fn parse_json(s: String) -> rusqlite::Result<Json> {
540    serde_json::from_str(&s).map_err(|e| {
541        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
542    })
543}
544
545/// Tiny object-safety shim so `line()` above can take both `Header` and
546/// `Record` without generics-in-closure gymnastics.
547mod erased {
548    use serde::Serialize;
549
550    use crate::error::Result;
551
552    pub(super) trait Line {
553        fn to_json_line(&self) -> Result<String>;
554    }
555
556    impl<T: Serialize> Line for T {
557        fn to_json_line(&self) -> Result<String> {
558            Ok(serde_json::to_string(self)?)
559        }
560    }
561}