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 #[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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
106pub struct ImportStats {
107 pub nodes: usize,
109 pub edges: usize,
111 pub episodes: usize,
113 pub mentions: usize,
115 pub oplog: usize,
117}
118
119impl Grit {
120 pub fn export_jsonl<W: Write>(&self, mut out: W) -> Result<()> {
133 let conn = self.read();
134 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
301pub 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
545mod 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}