1use alloc::vec::Vec;
29
30use xxhash_rust::xxh3::xxh3_64;
31
32use crate::error::Error;
33
34const U32_BYTES: usize = core::mem::size_of::<u32>();
36const U64_BYTES: usize = core::mem::size_of::<u64>();
38const F32_BYTES: usize = core::mem::size_of::<f32>();
40
41const HEADER: usize = U32_BYTES + U32_BYTES;
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize))]
47pub struct JournalEntry<'a> {
48 pub op: u8,
50 pub payload: &'a [u8],
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct JournalScan<'a> {
57 pub entries: Vec<JournalEntry<'a>>,
59 pub truncated_tail: bool,
62}
63
64fn body_checksum(body: &[u8]) -> u32 {
67 xxh3_64(body) as u32
68}
69
70pub fn encode_entry(out: &mut Vec<u8>, op: u8, payload: &[u8]) {
73 let len = 1 + payload.len();
74 let len32 = u32::try_from(len).expect("journal payload fits u32 by construction");
75 out.reserve(HEADER + len);
76 out.extend_from_slice(&len32.to_le_bytes());
77 let check_pos = out.len();
80 out.extend_from_slice(&[0u8; U32_BYTES]);
81 out.push(op);
82 out.extend_from_slice(payload);
83 let check = body_checksum(&out[check_pos + U32_BYTES..]);
84 out[check_pos..check_pos + U32_BYTES].copy_from_slice(&check.to_le_bytes());
85}
86
87#[derive(Clone, Debug, PartialEq)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize))]
95pub enum Op<'a> {
96 Remember {
98 now: u64,
100 valid_from: u64,
103 entity: Option<&'a str>,
105 text: &'a str,
107 tags: Vec<&'a str>,
109 links: Vec<(&'a str, &'a str)>,
111 vector: Vec<f32>,
115 metadata: Vec<(&'a str, &'a str)>,
119 revises: crate::id::FactId,
121 assigned: crate::id::FactId,
124 },
125 Forget {
127 now: u64,
129 fact: crate::id::FactId,
131 },
132 Link {
134 now: u64,
136 src: &'a str,
138 rel: &'a str,
140 dst: &'a str,
142 provenance: crate::id::FactId,
144 },
145 Maintain {
147 now: u64,
149 mode: u8,
151 max_hnsw_inserts: u32,
153 },
154 Unlink {
156 now: u64,
158 src: &'a str,
160 rel: &'a str,
162 dst: &'a str,
164 },
165 RemoveTag {
167 now: u64,
169 tag: &'a str,
171 },
172 SetVectorSpace {
174 space: &'a str,
176 },
177}
178
179fn put_str(out: &mut Vec<u8>, s: &str) {
181 out.extend_from_slice(&(s.len() as u32).to_le_bytes());
182 out.extend_from_slice(s.as_bytes());
183}
184
185fn take_str<'a>(bytes: &'a [u8], at: &mut usize) -> Result<&'a str, Error> {
187 let len = take_u32(bytes, at)? as usize;
188 let end = at
189 .checked_add(len)
190 .filter(|&e| e <= bytes.len())
191 .ok_or(Error::Corrupt("journal string overruns its record"))?;
192 let s = core::str::from_utf8(&bytes[*at..end])
193 .map_err(|_| Error::Corrupt("journal string is not UTF-8"))?;
194 *at = end;
195 Ok(s)
196}
197
198fn take_u32(bytes: &[u8], at: &mut usize) -> Result<u32, Error> {
200 let end = *at + U32_BYTES;
201 if end > bytes.len() {
202 return Err(Error::Corrupt("journal record truncated inside a field"));
203 }
204 let v = u32::from_le_bytes(bytes[*at..end].try_into().unwrap());
205 *at = end;
206 Ok(v)
207}
208
209fn take_u64(bytes: &[u8], at: &mut usize) -> Result<u64, Error> {
211 let end = *at + U64_BYTES;
212 if end > bytes.len() {
213 return Err(Error::Corrupt("journal record truncated inside a field"));
214 }
215 let v = u64::from_le_bytes(bytes[*at..end].try_into().unwrap());
216 *at = end;
217 Ok(v)
218}
219
220fn take_vec_f32(bytes: &[u8], at: &mut usize) -> Result<Vec<f32>, Error> {
222 let count = take_u32(bytes, at)? as usize;
223 let end = *at as u64 + count as u64 * F32_BYTES as u64;
229 if end > bytes.len() as u64 {
230 return Err(Error::Corrupt("journal vector overruns its record"));
231 }
232 let end = end as usize;
233 let mut v = Vec::with_capacity(count);
234 let mut p = *at;
235 while p < end {
236 v.push(f32::from_le_bytes(
237 bytes[p..p + F32_BYTES].try_into().unwrap(),
238 ));
239 p += F32_BYTES;
240 }
241 *at = end;
242 Ok(v)
243}
244
245impl<'a> Op<'a> {
246 pub fn encode(&self, out: &mut Vec<u8>) {
249 let mut payload = Vec::new();
250 let op = match self {
251 Op::Remember {
252 now,
253 valid_from,
254 entity,
255 text,
256 tags,
257 links,
258 vector,
259 metadata,
260 revises,
261 assigned,
262 } => {
263 payload.extend_from_slice(&now.to_le_bytes());
264 payload.extend_from_slice(&valid_from.to_le_bytes());
265 payload.extend_from_slice(&revises.0.to_le_bytes());
266 payload.extend_from_slice(&assigned.0.to_le_bytes());
267 match entity {
268 Some(name) => {
269 payload.push(1);
270 put_str(&mut payload, name);
271 }
272 None => payload.push(0),
273 }
274 put_str(&mut payload, text);
275 payload.push(tags.len() as u8);
276 for tag in tags {
277 put_str(&mut payload, tag);
278 }
279 payload.push(links.len() as u8);
280 for (rel, dst) in links {
281 put_str(&mut payload, rel);
282 put_str(&mut payload, dst);
283 }
284 payload.extend_from_slice(&(vector.len() as u32).to_le_bytes());
285 for &x in vector {
286 payload.extend_from_slice(&x.to_le_bytes());
287 }
288 payload.extend_from_slice(&(metadata.len() as u32).to_le_bytes());
289 for (k, v) in metadata {
290 put_str(&mut payload, k);
291 put_str(&mut payload, v);
292 }
293 if revises.is_none() { 1 } else { 2 }
294 }
295 Op::Forget { now, fact } => {
296 payload.extend_from_slice(&now.to_le_bytes());
297 payload.extend_from_slice(&fact.0.to_le_bytes());
298 3
299 }
300 Op::Link {
301 now,
302 src,
303 rel,
304 dst,
305 provenance,
306 } => {
307 payload.extend_from_slice(&now.to_le_bytes());
308 payload.extend_from_slice(&provenance.0.to_le_bytes());
309 put_str(&mut payload, src);
310 put_str(&mut payload, rel);
311 put_str(&mut payload, dst);
312 4
313 }
314 Op::Unlink { now, src, rel, dst } => {
315 payload.extend_from_slice(&now.to_le_bytes());
316 put_str(&mut payload, src);
317 put_str(&mut payload, rel);
318 put_str(&mut payload, dst);
319 6
320 }
321 Op::Maintain {
322 now,
323 mode,
324 max_hnsw_inserts,
325 } => {
326 payload.extend_from_slice(&now.to_le_bytes());
327 payload.push(*mode);
328 payload.extend_from_slice(&max_hnsw_inserts.to_le_bytes());
329 5
330 }
331 Op::RemoveTag { now, tag } => {
332 payload.extend_from_slice(&now.to_le_bytes());
333 put_str(&mut payload, tag);
334 7
335 }
336 Op::SetVectorSpace { space } => {
337 put_str(&mut payload, space);
338 8
339 }
340 };
341 encode_entry(out, op, &payload);
342 }
343
344 pub fn decode(op: u8, payload: &'a [u8]) -> Result<Op<'a>, Error> {
349 use crate::id::FactId;
350 let at = &mut 0usize;
351 let decoded = match op {
352 1 | 2 => {
353 let now = take_u64(payload, at)?;
354 let valid_from = take_u64(payload, at)?;
355 let revises = FactId(take_u32(payload, at)?);
356 let assigned = FactId(take_u32(payload, at)?);
357 if (op == 2) == revises.is_none() {
358 return Err(Error::Corrupt("journal revises field disagrees with op"));
359 }
360 let entity = match payload.get(*at) {
361 Some(0) => {
362 *at += 1;
363 None
364 }
365 Some(1) => {
366 *at += 1;
367 Some(take_str(payload, at)?)
368 }
369 _ => return Err(Error::Corrupt("journal entity flag is invalid")),
370 };
371 let text = take_str(payload, at)?;
372 let tag_cnt = *payload
373 .get(*at)
374 .ok_or(Error::Corrupt("journal record truncated inside a field"))?;
375 *at += 1;
376 let mut tags = Vec::with_capacity(tag_cnt as usize);
377 for _ in 0..tag_cnt {
378 tags.push(take_str(payload, at)?);
379 }
380 let link_cnt = *payload
381 .get(*at)
382 .ok_or(Error::Corrupt("journal record truncated inside a field"))?;
383 *at += 1;
384 let mut links = Vec::with_capacity(link_cnt as usize);
385 for _ in 0..link_cnt {
386 let rel = take_str(payload, at)?;
387 let dst = take_str(payload, at)?;
388 links.push((rel, dst));
389 }
390 let vector = take_vec_f32(payload, at)?;
391 let meta_cnt = take_u32(payload, at)?;
392 let mut metadata = Vec::new();
393 for _ in 0..meta_cnt {
394 let k = take_str(payload, at)?;
395 let v = take_str(payload, at)?;
396 metadata.push((k, v));
397 }
398 Op::Remember {
399 now,
400 valid_from,
401 entity,
402 text,
403 tags,
404 links,
405 vector,
406 metadata,
407 revises,
408 assigned,
409 }
410 }
411 3 => Op::Forget {
412 now: take_u64(payload, at)?,
413 fact: FactId(take_u32(payload, at)?),
414 },
415 4 => {
416 let now = take_u64(payload, at)?;
417 let provenance = FactId(take_u32(payload, at)?);
418 let src = take_str(payload, at)?;
419 let rel = take_str(payload, at)?;
420 let dst = take_str(payload, at)?;
421 Op::Link {
422 now,
423 src,
424 rel,
425 dst,
426 provenance,
427 }
428 }
429 5 => {
430 let now = take_u64(payload, at)?;
431 let (mode, max_hnsw_inserts) = if *at == payload.len() {
432 (0, u32::MAX)
433 } else {
434 let mode = *payload
435 .get(*at)
436 .ok_or(Error::Corrupt("journal record truncated inside a field"))?;
437 *at += 1;
438 let max_hnsw_inserts = take_u32(payload, at)?;
439 (mode, max_hnsw_inserts)
440 };
441 Op::Maintain {
442 now,
443 mode,
444 max_hnsw_inserts,
445 }
446 }
447 6 => {
448 let now = take_u64(payload, at)?;
449 let src = take_str(payload, at)?;
450 let rel = take_str(payload, at)?;
451 let dst = take_str(payload, at)?;
452 Op::Unlink { now, src, rel, dst }
453 }
454 7 => {
455 let now = take_u64(payload, at)?;
456 let tag = take_str(payload, at)?;
457 Op::RemoveTag { now, tag }
458 }
459 8 => Op::SetVectorSpace {
460 space: take_str(payload, at)?,
461 },
462 _ => return Err(Error::Corrupt("unknown journal op")),
463 };
464 if *at != payload.len() {
465 return Err(Error::Corrupt("journal record has trailing bytes"));
466 }
467 Ok(decoded)
468 }
469}
470
471pub fn scan(journal: &[u8]) -> Result<JournalScan<'_>, Error> {
474 let mut entries = Vec::new();
475 let mut pos = 0usize;
476 while pos < journal.len() {
477 let rest = &journal[pos..];
478 if rest.len() < HEADER {
479 return Ok(JournalScan {
480 entries,
481 truncated_tail: true,
482 });
483 }
484 let len = u32::from_le_bytes(rest[..U32_BYTES].try_into().unwrap()) as usize;
485 if len == 0 {
486 return Err(Error::Corrupt("journal record with zero length"));
487 }
488 let Some(body) = HEADER
494 .checked_add(len)
495 .and_then(|end| rest.get(HEADER..end))
496 else {
497 return Ok(JournalScan {
498 entries,
499 truncated_tail: true,
500 });
501 };
502 let want = u32::from_le_bytes(rest[U32_BYTES..HEADER].try_into().unwrap());
503 if body_checksum(body) != want {
504 if pos + HEADER + len == journal.len() {
505 return Ok(JournalScan {
506 entries,
507 truncated_tail: true,
508 });
509 }
510 return Err(Error::Corrupt("journal checksum mismatch mid-stream"));
511 }
512 entries.push(JournalEntry {
513 op: body[0],
514 payload: &body[1..],
515 });
516 pos += HEADER + len;
517 }
518 Ok(JournalScan {
519 entries,
520 truncated_tail: false,
521 })
522}