1use std::collections::{BTreeMap, BTreeSet};
16use std::fs::{self, File, OpenOptions};
17use std::io::{Read, Seek, SeekFrom, Write};
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22
23use crate::model::{Note, Page, PageStatus, Section};
24use crate::replica::{DocSpace, LogEvent, assemble};
25use crate::write::EventSpec;
26
27pub const JOURNAL: &str = "journal.jsonl";
29pub const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024;
32pub const MAX_EVENTS_PER_REVISION: usize = 4096;
34const MAX_LINE_BYTES: usize = 256 * 1024;
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Author {
40 pub kind: String,
42 pub label: String,
44}
45
46impl Author {
47 pub fn agent(label: impl Into<String>) -> Self {
48 Self {
49 kind: "agent".into(),
50 label: label.into(),
51 }
52 }
53
54 pub fn person(label: impl Into<String>) -> Self {
55 Self {
56 kind: "person".into(),
57 label: label.into(),
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct Entry {
65 pub rev: u64,
67 pub seq: u64,
69 pub at: String,
71 pub author: Author,
72 pub action: String,
74 pub op: String,
75 pub target: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub node_type: Option<String>,
78 pub body: Map<String, Value>,
79}
80
81impl Entry {
82 pub fn event_id(&self) -> String {
84 format!("local_{:012}", self.seq)
85 }
86
87 fn log_event(&self) -> LogEvent {
88 LogEvent {
89 seq: self.seq,
90 event_id: self.event_id(),
91 op: self.op.clone(),
92 target: Some(self.target.clone()),
93 node_type: self.node_type.clone(),
94 body: Some(Value::Object(self.body.clone())),
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum LocalError {
102 Io(String),
103 Corrupt {
105 line: usize,
106 },
107 Full,
109 TooLarge(usize),
111}
112
113impl std::fmt::Display for LocalError {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 match self {
116 Self::Io(e) => write!(f, "local docs: {e}"),
117 Self::Corrupt { line } => write!(f, "local docs: journal line {line} is unreadable"),
118 Self::Full => write!(
119 f,
120 "local docs: the journal reached {} MiB",
121 MAX_JOURNAL_BYTES / (1024 * 1024)
122 ),
123 Self::TooLarge(n) => write!(
124 f,
125 "local docs: {n} events in one write (at most {MAX_EVENTS_PER_REVISION})"
126 ),
127 }
128 }
129}
130
131impl std::error::Error for LocalError {}
132
133fn io(e: &std::io::Error) -> LocalError {
134 LocalError::Io(e.to_string())
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
139pub struct Revision {
140 pub rev: u64,
141 pub at: String,
142 pub author: Author,
143 pub action: String,
144 pub events: usize,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct Committed {
151 pub rev: Option<u64>,
153 pub events: usize,
154}
155
156#[derive(Debug, Clone)]
158pub struct LocalDocs {
159 dir: PathBuf,
160}
161
162impl LocalDocs {
163 pub fn at(dir: impl Into<PathBuf>) -> Self {
164 Self { dir: dir.into() }
165 }
166
167 pub fn dir(&self) -> &Path {
168 &self.dir
169 }
170
171 fn journal(&self) -> PathBuf {
172 self.dir.join(JOURNAL)
173 }
174
175 pub fn entries(&self) -> Result<Vec<Entry>, LocalError> {
180 match File::open(self.journal()) {
181 Ok(mut f) => read_entries(&mut f),
182 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
183 Err(e) => Err(io(&e)),
184 }
185 }
186
187 pub fn space(&self) -> Result<DocSpace, LocalError> {
192 Ok(space_of(&self.entries()?, None, &[]))
193 }
194
195 pub fn pages(&self) -> Result<Vec<Page>, LocalError> {
202 Ok(pages_of(&self.space()?))
203 }
204
205 pub fn commit<E: From<LocalError>>(
211 &self,
212 author: &Author,
213 at: &str,
214 action: &str,
215 plan: impl FnOnce(&DocSpace) -> Result<Vec<EventSpec>, E>,
216 ) -> Result<Committed, E> {
217 create_private_dir(&self.dir)?;
218 let mut file = open_journal(&self.journal())?;
219 let _lock = Lock::exclusive(&file)?;
220 let len = file.metadata().map_err(|e| io(&e))?.len();
221 if len >= MAX_JOURNAL_BYTES {
222 return Err(LocalError::Full.into());
223 }
224 file.seek(SeekFrom::Start(0)).map_err(|e| io(&e))?;
225 let entries = read_entries(&mut file)?;
226 let mut specs = plan(&space_of(&entries, None, &[]))?;
227 if specs.is_empty() {
228 return Ok(Committed {
229 rev: None,
230 events: 0,
231 });
232 }
233 if specs.len() > MAX_EVENTS_PER_REVISION {
234 return Err(LocalError::TooLarge(specs.len()).into());
235 }
236 let rev = entries.last().map_or(1, |e| e.rev + 1);
237 let first = entries.last().map_or(1, |e| e.seq + 1);
238 let ids: Vec<String> = (0..specs.len())
239 .map(|i| format!("local_{:012}", first + i as u64))
240 .collect();
241 crate::write::chain(&mut specs, &ids);
242 let mut out = String::new();
243 for (i, spec) in specs.iter().enumerate() {
244 let entry = Entry {
245 rev,
246 seq: first + i as u64,
247 at: at.to_owned(),
248 author: author.clone(),
249 action: action.to_owned(),
250 op: spec.op.to_owned(),
251 target: spec.target.clone(),
252 node_type: spec.node_type.map(str::to_owned),
253 body: spec.body.clone(),
254 };
255 let line = serde_json::to_string(&entry).map_err(|e| LocalError::Io(e.to_string()))?;
256 out.push_str(&line);
257 out.push('\n');
258 }
259 if len + out.len() as u64 > MAX_JOURNAL_BYTES {
260 return Err(LocalError::Full.into());
261 }
262 let keep = complete_len(&mut file, len)?;
265 if keep != len {
266 file.set_len(keep).map_err(|e| io(&e))?;
267 }
268 file.seek(SeekFrom::Start(keep)).map_err(|e| io(&e))?;
269 file.write_all(out.as_bytes()).map_err(|e| io(&e))?;
270 file.sync_data().map_err(|e| io(&e))?;
271 Ok(Committed {
272 rev: Some(rev),
273 events: specs.len(),
274 })
275 }
276}
277
278pub fn space_of(entries: &[Entry], upto: Option<u64>, extra: &[EventSpec]) -> DocSpace {
281 let mut log: Vec<LogEvent> = entries
282 .iter()
283 .filter(|e| upto.is_none_or(|r| e.rev <= r))
284 .map(Entry::log_event)
285 .collect();
286 let next = log.last().map_or(1, |e| e.seq + 1);
287 log.extend(extra.iter().enumerate().map(|(i, s)| LogEvent {
288 seq: next + i as u64,
289 event_id: format!("check_{i}"),
290 op: s.op.to_owned(),
291 target: Some(s.target.clone()),
292 node_type: s.node_type.map(str::to_owned),
293 body: Some(Value::Object(s.body.clone())),
294 }));
295 assemble(&log)
296}
297
298fn page_nodes(entries: &[Entry], page: &str) -> BTreeSet<String> {
301 let mut nodes = BTreeSet::from([page.to_owned()]);
302 let mut notes_on: Vec<(String, String)> = Vec::new();
303 for e in entries.iter().filter(|e| e.op == "edge.add") {
304 let get = |k: &str| e.body.get(k).and_then(Value::as_str).unwrap_or_default();
305 match get("kind") {
306 "part_of" if get("to") == page => {
307 nodes.insert(get("from").to_owned());
308 }
309 "about" => notes_on.push((get("from").to_owned(), get("to").to_owned())),
310 _ => {}
311 }
312 }
313 for (note, about) in notes_on {
314 if nodes.contains(&about) {
315 nodes.insert(note);
316 }
317 }
318 nodes
319}
320
321pub fn history(entries: &[Entry], page: &str) -> Vec<Revision> {
323 let nodes = page_nodes(entries, page);
324 let touches = |e: &Entry| {
325 nodes.contains(&e.target)
326 || (e.op == "edge.add"
327 && ["from", "to"].iter().any(|k| {
328 e.body
329 .get(*k)
330 .and_then(Value::as_str)
331 .is_some_and(|v| nodes.contains(v))
332 }))
333 };
334 let mut out: BTreeMap<u64, Revision> = BTreeMap::new();
335 for e in entries.iter().filter(|e| touches(e)) {
336 out.entry(e.rev)
337 .or_insert_with(|| Revision {
338 rev: e.rev,
339 at: e.at.clone(),
340 author: e.author.clone(),
341 action: e.action.clone(),
342 events: 0,
343 })
344 .events += 1;
345 }
346 out.into_values().collect()
347}
348
349pub fn last_revisions(entries: &[Entry]) -> BTreeMap<String, Revision> {
351 let mut owner: BTreeMap<&str, &str> = BTreeMap::new();
352 for e in entries.iter().filter(|e| e.op == "edge.add") {
353 let get = |k: &str| e.body.get(k).and_then(Value::as_str);
354 if let (Some("part_of" | "about"), Some(from), Some(to)) =
355 (get("kind"), get("from"), get("to"))
356 {
357 let page = owner.get(to).copied().unwrap_or(to);
358 owner.insert(from, page);
359 }
360 }
361 let mut out: BTreeMap<String, Revision> = BTreeMap::new();
362 for e in entries {
363 let page = match e.op.as_str() {
364 "edge.add" => e
365 .body
366 .get("from")
367 .and_then(Value::as_str)
368 .map(|from| owner.get(from).copied().unwrap_or(from)),
369 _ => Some(owner.get(e.target.as_str()).copied().unwrap_or(&e.target)),
370 };
371 let Some(page) = page else { continue };
372 let slot = out.entry(page.to_owned()).or_insert_with(|| Revision {
373 rev: 0,
374 at: String::new(),
375 author: e.author.clone(),
376 action: String::new(),
377 events: 0,
378 });
379 if slot.rev != e.rev {
380 *slot = Revision {
381 rev: e.rev,
382 at: e.at.clone(),
383 author: e.author.clone(),
384 action: e.action.clone(),
385 events: 0,
386 };
387 }
388 slot.events += 1;
389 }
390 out
391}
392
393pub fn page_id_of(entries: &[Entry], slug: &str) -> Option<String> {
396 let mut last: Option<&str> = None;
397 for e in entries {
398 let named = match e.op.as_str() {
399 "node.add" if e.node_type.as_deref() == Some("page") => e.body.get("slug"),
400 "field.set" if e.body.get("field").and_then(Value::as_str) == Some("slug") => {
401 e.body.get("value")
402 }
403 _ => None,
404 };
405 if named.and_then(Value::as_str) == Some(slug) {
406 last = Some(&e.target);
407 }
408 }
409 last.map(str::to_owned)
410}
411
412fn pages_of(space: &DocSpace) -> Vec<Page> {
413 let mut out: Vec<Page> = space
414 .pages
415 .values()
416 .map(|p| Page {
417 slug: p.slug().to_owned(),
418 title: p.title().to_owned(),
419 kind: p.page.str("type").unwrap_or("reference").to_owned(),
420 status: p
421 .page
422 .str("status")
423 .and_then(PageStatus::parse)
424 .unwrap_or(PageStatus::Draft),
425 sections: p
426 .sections
427 .iter()
428 .map(|s| Section {
429 order: s.order,
430 text: s.body.clone(),
431 })
432 .collect(),
433 anchors: p
434 .page
435 .fields
436 .get("anchors")
437 .and_then(Value::as_array)
438 .into_iter()
439 .flatten()
440 .filter_map(crate::write::anchor_from_value)
441 .collect(),
442 notes: p
443 .notes
444 .iter()
445 .filter(|n| n.open)
446 .map(|n| Note {
447 body: n.body.clone(),
448 covers: n.covers.iter().map(|c| c.reference.clone()).collect(),
449 })
450 .collect(),
451 })
452 .collect();
453 out.sort_by(|a, b| a.slug.cmp(&b.slug));
454 out
455}
456
457fn read_entries(file: &mut File) -> Result<Vec<Entry>, LocalError> {
458 let len = file.metadata().map_err(|e| io(&e))?.len();
459 if len > MAX_JOURNAL_BYTES {
460 return Err(LocalError::Full);
461 }
462 let mut raw = String::new();
463 file.take(MAX_JOURNAL_BYTES)
464 .read_to_string(&mut raw)
465 .map_err(|e| io(&e))?;
466 let mut out = Vec::new();
467 let mut lines = raw.split_inclusive('\n').enumerate().peekable();
468 while let Some((i, line)) = lines.next() {
469 if !line.ends_with('\n') && lines.peek().is_none() {
472 break;
473 }
474 let line = line.trim_end();
475 if line.is_empty() {
476 continue;
477 }
478 if line.len() > MAX_LINE_BYTES {
479 return Err(LocalError::Corrupt { line: i + 1 });
480 }
481 let entry: Entry =
482 serde_json::from_str(line).map_err(|_| LocalError::Corrupt { line: i + 1 })?;
483 if out
484 .last()
485 .is_some_and(|p: &Entry| entry.seq <= p.seq || entry.rev < p.rev)
486 {
487 return Err(LocalError::Corrupt { line: i + 1 });
488 }
489 out.push(entry);
490 }
491 Ok(out)
492}
493
494fn complete_len(file: &mut File, len: u64) -> Result<u64, LocalError> {
496 if len == 0 {
497 return Ok(0);
498 }
499 let mut last = [0u8; 1];
500 file.seek(SeekFrom::Start(len - 1)).map_err(|e| io(&e))?;
501 file.read_exact(&mut last).map_err(|e| io(&e))?;
502 if last[0] == b'\n' {
503 return Ok(len);
504 }
505 let mut raw = Vec::new();
506 file.seek(SeekFrom::Start(0)).map_err(|e| io(&e))?;
507 file.read_to_end(&mut raw).map_err(|e| io(&e))?;
508 Ok(raw
509 .iter()
510 .rposition(|b| *b == b'\n')
511 .map_or(0, |i| i as u64 + 1))
512}
513
514fn create_private_dir(dir: &Path) -> Result<(), LocalError> {
515 fs::create_dir_all(dir).map_err(|e| io(&e))?;
516 #[cfg(unix)]
517 {
518 use std::os::unix::fs::PermissionsExt;
519 fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).map_err(|e| io(&e))?;
520 }
521 Ok(())
522}
523
524fn open_journal(path: &Path) -> Result<File, LocalError> {
525 let mut options = OpenOptions::new();
526 options.read(true).write(true).create(true).truncate(false);
527 #[cfg(unix)]
528 {
529 use std::os::unix::fs::OpenOptionsExt;
530 options.mode(0o600);
531 }
532 options.open(path).map_err(|e| io(&e))
533}
534
535struct Lock(File);
540
541impl Lock {
542 fn exclusive(file: &File) -> Result<Self, LocalError> {
543 let held = file.try_clone().map_err(|e| io(&e))?;
544 fs2::FileExt::lock_exclusive(&held).map_err(|e| io(&e))?;
545 Ok(Self(held))
546 }
547}
548
549impl Drop for Lock {
550 fn drop(&mut self) {
551 let _ = fs2::FileExt::unlock(&self.0);
552 }
553}
554
555#[cfg(test)]
556#[path = "local_tests.rs"]
557mod tests;