1use notebook::Replica;
2use onestore::op::{Op, PageOp};
3use onestore::{
4 ExGuid, RevisionIndex, Store,
5 document::{Document, Kind},
6 page::{PageObject, Paragraph},
7};
8use std::{
9 io::{self, BufRead, Write},
10 path::Path,
11};
12
13fn content(bytes: &[u8]) -> (ExGuid, ExGuid, String) {
14 let store = Store::parse(bytes).unwrap();
15 assert!(store.checksum_mismatches.is_empty());
16 let index = RevisionIndex::parse(&store).unwrap();
17 index.validate_current().unwrap();
18 let document = Document::parse(&index).unwrap();
19 document
20 .spaces
21 .iter()
22 .find_map(|(sid, space)| {
23 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
24 revision
25 .nodes
26 .iter()
27 .find_map(|(oid, node)| match &node.kind {
28 Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())),
29 _ => None,
30 })
31 })
32 .unwrap()
33}
34
35/// The images the queue leaves and started from, as a recovery archive records them.
36fn images(cache: &Replica, path: &Path) -> Result<(Vec<u8>, Vec<u8>), Box<dyn std::error::Error>> {
37 let archive = path.with_extension(format!("probe-{}", std::process::id()));
38 cache.export_recovery(&archive)?;
39 let recovery = notebook::Recovery::open(&archive)?;
40 let images = (recovery.snapshot()?, recovery.remote_snapshot()?);
41 drop(recovery);
42 std::fs::remove_file(&archive)?;
43 Ok(images)
44}
45
46fn payload(operation: u64, size: usize) -> String {
47 format!("{operation}:🦀{}", "x".repeat(size))
48}
49
50fn main() -> Result<(), Box<dyn std::error::Error>> {
51 let args: Vec<_> = std::env::args().collect();
52 let mode = &args[1];
53 let path = Path::new(&args[2]);
54 let operation_kind =
55 std::env::var("ONESTORE_CACHE_PROBE_OPERATION").unwrap_or_else(|_| "text".into());
56 assert!(matches!(
57 operation_kind.as_str(),
58 "text" | "insert" | "format"
59 ));
60 let size = match std::env::var("ONESTORE_CACHE_PROBE_BYTES") {
61 Ok(value) => value.parse::<usize>()?,
62 Err(std::env::VarError::NotPresent) => 2 * 1024 * 1024,
63 Err(error) => return Err(error.into()),
64 };
65 assert!(size > 0 && size <= 2 * 1024 * 1024);
66 let seed = std::env::var_os("ONESTORE_CACHE_PROBE_SOURCE")
67 .map(std::fs::read)
68 .transpose()?;
69 if mode == "init" {
70 let source = match seed {
71 Some(source) => source,
72 None => onestore::create_section(
73 "cache.one",
74 &if operation_kind == "format" {
75 payload(0, size)
76 } else {
77 "Base".into()
78 },
79 "Fixture",
80 )?,
81 };
82 Replica::create(path, &source)?;
83 return Ok(());
84 }
85 let cache = Replica::open(path)?;
86 if mode == "read" {
87 let (snapshot, base) = images(&cache, path)?;
88 let (sid, target, mut expected) = content(&base);
89 let store = Store::parse(&snapshot)?;
90 assert!(store.checksum_mismatches.is_empty());
91 let index = RevisionIndex::parse(&store)?;
92 index.validate_current()?;
93 let document = Document::parse(&index)?;
94 let space = &document.spaces[&sid];
95 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
96 let mut operations = Vec::new();
97 let mut ids = Vec::new();
98 let mut font_size = None;
99 for pending in cache.pending()? {
100 let [Op::Page { op, .. }] = &pending.edit.ops[..] else {
101 panic!("Unexpected edit for this fixture")
102 };
103 let operation = match (operation_kind.as_str(), op) {
104 ("text", PageOp::Text { text, range, with }) => {
105 assert_eq!(*text, target);
106 assert_eq!(*range, 0..u32::try_from(expected.encode_utf16().count())?);
107 let operation: u64 = with.split_once(':').unwrap().0.parse()?;
108 expected = payload(operation, size);
109 assert_eq!(*with, expected);
110 operation
111 }
112 (
113 "insert",
114 PageOp::Add {
115 object: PageObject::Outline(outline),
116 ..
117 },
118 ) => {
119 let text = outline.paragraphs[0].text().unwrap().text.text();
120 let operation: u64 = text.split_once(':').unwrap().0.parse()?;
121 assert_eq!(text, payload(operation, size));
122 assert_eq!(
123 (outline.layout.x, outline.layout.y),
124 (Some(144.0), Some(operation as f32 * 72.0))
125 );
126 assert!(revision.nodes.contains_key(&outline.id));
127 operation
128 }
129 ("format", PageOp::Format { text, set, .. }) => {
130 assert_eq!(*text, target);
131 let [onestore::TextAttribute::FontSize(value)] = set[..] else {
132 panic!("Expected a font size")
133 };
134 assert!((7.0..=130.0).contains(&value) && value.fract() == 0.0);
135 font_size = Some(value);
136 value as u64 - 6
137 }
138 other => panic!("Unexpected op {other:?}"),
139 };
140 assert!(operations.last().is_none_or(|last| *last < operation));
141 assert!(ids.last().is_none_or(|last| *last < pending.id));
142 operations.push(operation);
143 ids.push(pending.id);
144 }
145 assert!(matches!(&revision.nodes[&target].kind,Kind::RichText{text,..} if *text==expected));
146 if let Some(font_size) = font_size {
147 assert!(
148 revision
149 .text_runs(target)?
150 .iter()
151 .all(|run| run.format.font_size == Some(font_size))
152 );
153 }
154 if operations.is_empty() {
155 assert!(snapshot == base, "An empty local queue changed its image");
156 }
157 if let Some(output) = args.get(3) {
158 std::fs::write(output, &snapshot)?;
159 }
160 println!(
161 "{}",
162 serde_json::json!({"operations": operations, "ids": ids, "section_bytes": snapshot.len(), "complete_payloads": true})
163 );
164 return Ok(());
165 }
166 assert_eq!(mode, "edit");
167 assert!(
168 matches!(Replica::open(path), Err(notebook::Error::Database(error)) if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy))
169 );
170 println!("ready");
171 io::stdout().flush()?;
172 let input = io::stdin();
173 let mut lines = input.lock().lines();
174 let instruction = lines.next().unwrap()?;
175 let (operation, acknowledgement) = instruction.split_once(' ').unwrap();
176 let operation: u64 = operation.parse()?;
177 let (sid, oid, text) = content(&images(&cache, path)?.0);
178 let replacement = payload(operation, size);
179 println!("editing {operation}");
180 io::stdout().flush()?;
181 let end = u32::try_from(text.encode_utf16().count())?;
182 let op = match operation_kind.as_str() {
183 "text" => PageOp::Text {
184 text: oid,
185 range: 0..end,
186 with: replacement.clone(),
187 },
188 "insert" => {
189 let page = cache.page(sid)?;
190 let template = page
191 .objects
192 .iter()
193 .find_map(|object| match object {
194 PageObject::Outline(outline) => outline
195 .paragraphs
196 .iter()
197 .find(|p| p.text().is_some_and(|t| t.id == oid)),
198 _ => None,
199 })
200 .expect("target paragraph")
201 .clone();
202 let mut fresh = template.clone();
203 fresh.id = onestore::page::text::new_id()?;
204 fresh.parent = None;
205 fresh.level = 1;
206 fresh.lists.clear();
207 fresh.tags.clear();
208 fresh.style = None;
209 let format = template.text().unwrap().text.format_at(0)?.clone();
210 fresh.content = onestore::page::ParagraphContent::Text(onestore::page::TextObject {
211 id: onestore::page::text::new_id()?,
212 date_field: None,
213 text: Paragraph::new(replacement.clone(), format),
214 tags: Vec::new(),
215 });
216 PageOp::Add {
217 object: PageObject::Outline(onestore::page::Outline {
218 id: onestore::page::text::new_id()?,
219 title: false,
220 min_width: None,
221 layout: onestore::document::Layout {
222 x: Some(144.0),
223 y: Some(operation as f32 * 72.0),
224 ..Default::default()
225 },
226 indents: Vec::new(),
227 paragraphs: vec![fresh],
228 unsupported: Vec::new(),
229 }),
230 before: page
231 .objects
232 .iter()
233 .find(|o| matches!(o, PageObject::Title(_)))
234 .map(PageObject::id),
235 }
236 }
237 "format" => {
238 assert!((1..=124).contains(&operation));
239 PageOp::Format {
240 text: oid,
241 range: 0..end,
242 set: vec![onestore::TextAttribute::FontSize(6.0 + operation as f32)],
243 clear: Vec::new(),
244 }
245 }
246 _ => unreachable!(),
247 };
248 let id = cache.apply(
249 "Fixture",
250 onestore::op::Edit {
251 at: 133_000_000_000_000_000,
252 ops: vec![Op::Page { space: sid, op }],
253 },
254 )?;
255 if acknowledgement == "unack" {
256 println!("durable {operation} {id}");
257 } else {
258 println!("ack {operation} {id}");
259 }
260 io::stdout().flush()?;
261 lines.next().transpose()?;
262 Ok(())
263}