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