1use 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#[derive(Debug, Serialize, Deserialize)]
23struct Header {
24 grit_export: u32,
25 schema_version: i64,
26}
27
28#[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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
103pub struct ImportStats {
104 pub nodes: usize,
106 pub edges: usize,
108 pub episodes: usize,
110 pub mentions: usize,
112 pub oplog: usize,
114}
115
116impl Grit {
117 pub fn export_jsonl<W: Write>(&self, mut out: W) -> Result<()> {
130 let conn = self.read();
131 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
297pub 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
531mod 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}