1use std::collections::HashMap;
2use std::fmt;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use serde_json::Value;
8
9use crate::catalog::TriggerEvent;
10use crate::crypto::Sealer;
11use crate::query::planner::{QueryResult, QuerySpec};
12use crate::table::{effect_document, matching, PreparedTxnEntry, Table, TableError, TriggerFiring, TxnOp};
13
14use super::journal::{self, Journal, JournalEntry, JournalHeader, JOURNAL_VERSION};
15use super::overlay::{StagedIndex, StagedOverlay};
16
17static UNIT_COUNTER: AtomicU64 = AtomicU64::new(0);
18
19#[cfg(unix)]
24fn process_tag() -> u32 {
25 std::process::id()
26}
27
28#[cfg(not(unix))]
29fn process_tag() -> u32 {
30 0
31}
32
33fn generate_id() -> String {
34 let counter = UNIT_COUNTER.fetch_add(1, Ordering::Relaxed);
35 format!(
36 "{}-{}-{counter}",
37 process_tag(),
38 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
39 )
40}
41
42#[derive(Debug)]
43pub enum TxnError {
44 UnknownTable(String),
45 AlreadyCommitted,
46 JournalPending(PathBuf),
47 Table(String, TableError),
48 Io(io::Error),
49}
50
51impl fmt::Display for TxnError {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 match self {
54 TxnError::UnknownTable(name) => write!(f, "unit of work names unknown table \"{name}\""),
55 TxnError::AlreadyCommitted => write!(f, "this unit of work was already committed — start a new one"),
56 TxnError::JournalPending(path) => write!(
57 f,
58 "an unfinished unit of work is still journalled in \"{}\" — reopen the database to let it finish",
59 path.display()
60 ),
61 TxnError::Table(name, error) => write!(f, "table \"{name}\": {error}"),
62 TxnError::Io(error) => error.fmt(f),
63 }
64 }
65}
66
67impl std::error::Error for TxnError {}
68
69impl From<io::Error> for TxnError {
70 fn from(value: io::Error) -> Self {
71 TxnError::Io(value)
72 }
73}
74
75#[derive(Debug, Clone, PartialEq)]
76pub struct UnitOfWorkResult {
77 pub id: String,
78 pub tables: Vec<String>,
79 pub operations: usize,
80}
81
82#[derive(Default)]
92pub struct UnitOfWork {
93 order: Vec<String>,
94 staged: HashMap<String, Vec<TxnOp>>,
95 operations: usize,
96 committed: bool,
97}
98
99pub struct PreparedUnit {
105 id: String,
106 touched: Vec<String>,
107 entries: Vec<(String, Vec<PreparedTxnEntry>)>,
108 operations: usize,
109}
110
111impl PreparedUnit {
112 pub fn is_empty(&self) -> bool {
114 self.operations == 0
115 }
116
117 pub fn tables(&self) -> &[String] {
120 &self.touched
121 }
122}
123
124pub enum StagedView {
127 Untouched,
128 Deleted,
129 Put(crate::schema::HaysonRecord),
130}
131
132impl UnitOfWork {
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 pub fn operations(&self) -> usize {
138 self.operations
139 }
140
141 pub fn tables(&self) -> &[String] {
142 &self.order
143 }
144
145 pub fn put(&mut self, table: &str, document: Value) -> &mut Self {
146 self.add(table, TxnOp::Put(document))
147 }
148
149 pub fn patch(&mut self, table: &str, key: impl Into<String>, changes: Value) -> &mut Self {
150 self.add(table, TxnOp::Patch(key.into(), changes))
151 }
152
153 pub fn delete(&mut self, table: &str, key: impl Into<String>) -> &mut Self {
154 self.add(table, TxnOp::Delete(key.into()))
155 }
156
157 fn add(&mut self, table: &str, op: TxnOp) -> &mut Self {
158 if !self.staged.contains_key(table) {
159 self.order.push(table.to_string());
160 }
161 self.staged.entry(table.to_string()).or_default().push(op);
162 self.operations += 1;
163 self
164 }
165
166 pub fn preview(&self, table_name: &str, key: &str, table: &Table) -> Result<StagedView, TableError> {
171 match self.staged.get(table_name) {
172 Some(ops) => StagedOverlay::new(ops).resolve(key, table),
173 None => Ok(StagedView::Untouched),
174 }
175 }
176
177 pub fn get(
178 &self,
179 table_name: &str,
180 key: &str,
181 table: &Table,
182 ) -> Result<Option<crate::schema::HaysonRecord>, TableError> {
183 match self.preview(table_name, key, table)? {
184 StagedView::Deleted => Ok(None),
185 StagedView::Put(record) => Ok(Some(record)),
186 StagedView::Untouched => Ok(table.get(key)?),
187 }
188 }
189
190 pub fn query(&self, table_name: &str, table: &Table, spec: &QuerySpec) -> Result<QueryResult, TableError> {
200 let overlay = StagedIndex::build(self.staged.get(table_name).map_or(&[][..], Vec::as_slice), table)?;
203 table.query_with_overlay(spec, &overlay, &overlay.corpus_delta())
204 }
205
206 pub fn commit(
228 &mut self,
229 tables: &mut HashMap<String, Table>,
230 journal_path: &Path,
231 ) -> Result<UnitOfWorkResult, TxnError> {
232 let prepared = self.prepare(tables)?;
233 self.journal(&prepared, journal_path)?;
234 apply_prepared(prepared, tables, journal_path)
235 }
236
237 pub fn prepare(&mut self, tables: &mut HashMap<String, Table>) -> Result<PreparedUnit, TxnError> {
242 if self.committed {
243 return Err(TxnError::AlreadyCommitted);
244 }
245 let id = generate_id();
246 if self.operations == 0 {
247 self.committed = true;
248 return Ok(PreparedUnit {
249 id,
250 touched: Vec::new(),
251 entries: Vec::new(),
252 operations: 0,
253 });
254 }
255
256 for name in &self.order {
257 if !tables.contains_key(name) {
258 return Err(TxnError::UnknownTable(name.clone()));
259 }
260 }
261
262 let mut entries: Vec<(String, Vec<PreparedTxnEntry>)> = Vec::with_capacity(self.order.len());
263 for name in &self.order {
264 let ops = self.staged.get(name).expect("resolved above");
265 let table = tables.get_mut(name).expect("resolved above");
266 let prepared = table
267 .prepare_txn_ops(ops)
268 .map_err(|error| TxnError::Table(name.clone(), error))?;
269 entries.push((name.clone(), prepared));
270 }
271
272 let mut touched = self.order.clone();
273 for (effect_table, effect_entries) in self.trigger_effect_entries(&entries, tables)? {
274 if !touched.contains(&effect_table) {
275 touched.push(effect_table.clone());
276 }
277 entries.push((effect_table, effect_entries));
278 }
279
280 Ok(PreparedUnit {
281 id,
282 touched,
283 entries,
284 operations: self.operations,
285 })
286 }
287
288 pub fn journal(&mut self, prepared: &PreparedUnit, journal_path: &Path) -> Result<(), TxnError> {
296 self.journal_sealed(prepared, journal_path, None)
297 }
298
299 pub fn journal_sealed(
301 &mut self,
302 prepared: &PreparedUnit,
303 journal_path: &Path,
304 sealer: Option<&Sealer>,
305 ) -> Result<(), TxnError> {
306 if prepared.is_empty() {
307 return Ok(());
308 }
309 if journal_path.exists() {
310 return Err(TxnError::JournalPending(journal_path.to_path_buf()));
311 }
312
313 let mut journal_entries = Vec::new();
314 for (name, entries) in &prepared.entries {
315 for entry in entries {
316 journal_entries.push(JournalEntry {
317 t: name.clone(),
318 s: entry.seq(),
319 l: entry.line().to_string(),
320 });
321 }
322 }
323
324 self.committed = true;
326 journal::write_journal_sealed(
327 journal_path,
328 &Journal {
329 header: JournalHeader {
330 journal_version: JOURNAL_VERSION,
331 kind: "unit".to_string(),
332 id: prepared.id.clone(),
333 started_at: chrono::Utc::now().timestamp_millis(),
334 tables: prepared.touched.clone(),
335 },
336 entries: journal_entries,
337 },
338 sealer,
339 )?;
340 Ok(())
341 }
342
343 fn trigger_effect_entries(
349 &self,
350 prepared: &[(String, Vec<PreparedTxnEntry>)],
351 tables: &mut HashMap<String, Table>,
352 ) -> Result<Vec<(String, Vec<PreparedTxnEntry>)>, TxnError> {
353 let mut documents_by_effect_table: HashMap<String, Vec<Value>> = HashMap::new();
354 for (name, entries) in prepared {
355 let triggers = tables.get(name).expect("resolved above").triggers();
356 for entry in entries {
357 let event = match entry.record() {
358 Some(_) => entry.upsert_event(),
359 None if entry.old().is_some() => TriggerEvent::Delete,
360 None => continue,
361 };
362 let firing = TriggerFiring {
363 event,
364 old: entry.old(),
365 new: entry.record(),
366 };
367 for trigger in matching(triggers, name, event) {
368 let document = effect_document(&trigger.effect, &firing);
369 documents_by_effect_table
370 .entry(trigger.effect.table.clone())
371 .or_default()
372 .push(document);
373 }
374 }
375 }
376
377 let mut effect_prepared = Vec::with_capacity(documents_by_effect_table.len());
378 for (effect_table, documents) in documents_by_effect_table {
379 let table = tables
380 .get_mut(&effect_table)
381 .ok_or_else(|| TxnError::UnknownTable(effect_table.clone()))?;
382 let ops: Vec<TxnOp> = documents.into_iter().map(TxnOp::Put).collect();
383 let entries = table
384 .prepare_txn_ops(&ops)
385 .map_err(|error| TxnError::Table(effect_table.clone(), error))?;
386 effect_prepared.push((effect_table, entries));
387 }
388 Ok(effect_prepared)
389 }
390}
391
392pub fn apply_prepared(
399 prepared: PreparedUnit,
400 tables: &mut HashMap<String, Table>,
401 journal_path: &Path,
402) -> Result<UnitOfWorkResult, TxnError> {
403 let PreparedUnit {
404 id,
405 touched,
406 entries,
407 operations,
408 } = prepared;
409 if operations == 0 {
410 return Ok(UnitOfWorkResult {
411 id,
412 tables: Vec::new(),
413 operations: 0,
414 });
415 }
416
417 for (name, entries) in entries {
418 let table = tables
419 .get_mut(&name)
420 .ok_or_else(|| TxnError::UnknownTable(name.clone()))?;
421 table.apply_txn_entries(entries).map_err(TxnError::Io)?;
422 }
423
424 journal::remove_journal(journal_path);
425 Ok(UnitOfWorkResult {
426 id,
427 tables: touched,
428 operations,
429 })
430}
431
432pub fn recover_incomplete(
437 journal_path: &Path,
438 tables: &mut HashMap<String, Table>,
439) -> Result<Option<UnitOfWorkResult>, TxnError> {
440 recover_incomplete_sealed(journal_path, tables, None)
441}
442
443pub fn recover_incomplete_sealed(
445 journal_path: &Path,
446 tables: &mut HashMap<String, Table>,
447 sealer: Option<&Sealer>,
448) -> Result<Option<UnitOfWorkResult>, TxnError> {
449 let Some(journal) = journal::read_journal_sealed(journal_path, sealer) else {
450 return Ok(None);
451 };
452
453 let mut by_table: HashMap<&str, Vec<(u64, String)>> = HashMap::new();
454 for entry in &journal.entries {
455 by_table
456 .entry(entry.t.as_str())
457 .or_default()
458 .push((entry.s, entry.l.clone()));
459 }
460
461 for name in &journal.header.tables {
462 if !tables.contains_key(name) {
463 return Err(TxnError::UnknownTable(name.clone()));
464 }
465 }
466
467 let mut applied_ops = 0;
468 for name in &journal.header.tables {
469 let table = tables.get_mut(name).expect("checked above");
470 if let Some(entries) = by_table.get(name.as_str()) {
471 applied_ops += table
472 .recover_txn_lines(entries)
473 .map_err(|error| TxnError::Table(name.clone(), error))?;
474 }
475 }
476
477 journal::remove_journal(journal_path);
478 Ok(Some(UnitOfWorkResult {
479 id: journal.header.id,
480 tables: journal.header.tables,
481 operations: applied_ops,
482 }))
483}
484
485#[cfg(test)]
486mod tests {
487 use serde_json::json;
488 use tempfile::tempdir;
489
490 use super::*;
491 use crate::query::builder::QueryBuilder;
492 use crate::query::planner::{OverlayStrategy, PlanOverlay};
493 use crate::query::predicates::ComparisonOp;
494 use crate::registry::fulltext_index::SearchMode;
495 use crate::schema::{parse_schema, TableSchema};
496
497 fn users_schema() -> TableSchema {
498 parse_schema(&json!({
499 "schemaVersion": 1,
500 "table": "users",
501 "primaryKey": "id",
502 "fields": {
503 "id": { "type": "string" },
504 "name": { "type": "string", "required": true },
505 },
506 }))
507 .unwrap()
508 }
509
510 fn orders_schema() -> TableSchema {
511 parse_schema(&json!({
512 "schemaVersion": 1,
513 "table": "orders",
514 "primaryKey": "id",
515 "fields": {
516 "id": { "type": "string" },
517 "userId": { "type": "string", "required": true },
518 "total": { "type": "int", "required": true },
519 },
520 }))
521 .unwrap()
522 }
523
524 struct Fixture {
525 _dir: tempfile::TempDir,
526 users_path: PathBuf,
527 orders_path: PathBuf,
528 journal_path: PathBuf,
529 }
530
531 fn fixture() -> Fixture {
532 let dir = tempdir().unwrap();
533 let users_path = dir.path().join("users.jsonl");
534 let orders_path = dir.path().join("orders.jsonl");
535 let journal_path = dir.path().join(super::journal::JOURNAL_FILE);
536 Fixture {
537 _dir: dir,
538 users_path,
539 orders_path,
540 journal_path,
541 }
542 }
543
544 fn open_tables(fx: &Fixture) -> HashMap<String, Table> {
545 let mut tables = HashMap::new();
546 tables.insert(
547 "users".to_string(),
548 Table::open(users_schema(), &fx.users_path).unwrap(),
549 );
550 tables.insert(
551 "orders".to_string(),
552 Table::open(orders_schema(), &fx.orders_path).unwrap(),
553 );
554 tables
555 }
556
557 #[test]
558 fn every_unit_id_is_its_own_even_when_two_are_minted_in_the_same_nanosecond() {
559 let first = generate_id();
560 let second = generate_id();
561
562 assert_ne!(first, second, "the counter is what keeps two ids apart, not the clock");
563 assert_eq!(first.split('-').count(), 3, "process tag, timestamp, counter");
564 }
565
566 #[test]
567 fn commits_across_two_tables_and_both_see_the_change() {
568 let fx = fixture();
569 let mut tables = open_tables(&fx);
570
571 let mut unit = UnitOfWork::new();
572 unit.put("users", json!({ "id": "u1", "name": "Ania" }));
573 unit.put("orders", json!({ "id": "o1", "userId": "u1", "total": 10 }));
574
575 let result = unit.commit(&mut tables, &fx.journal_path).unwrap();
576 assert_eq!(result.operations, 2);
577 assert_eq!(result.tables, vec!["users".to_string(), "orders".to_string()]);
578 assert!(!fx.journal_path.exists());
579
580 assert_eq!(
581 tables.get("users").unwrap().get("u1").unwrap().unwrap().get("name"),
582 Some(&json!("Ania"))
583 );
584 assert_eq!(
585 tables.get("orders").unwrap().get("o1").unwrap().unwrap().get("total"),
586 Some(&json!(10))
587 );
588 }
589
590 #[test]
591 fn a_unit_reading_and_committing_into_a_warn_table_never_sees_the_stray_field() {
592 let fx = fixture();
593 let mut tables = open_tables(&fx);
594 tables
595 .get_mut("users")
596 .unwrap()
597 .set_validation(crate::catalog::CatalogValidation::Warn);
598
599 let mut unit = UnitOfWork::new();
600 unit.put("users", json!({ "id": "u1", "name": "Ania", "nickname": "Anka" }));
601
602 let previewed = unit.get("users", "u1", tables.get("users").unwrap()).unwrap().unwrap();
603 assert_eq!(previewed.get("name"), Some(&json!("Ania")));
604 assert_eq!(previewed.get("nickname"), None);
605
606 unit.commit(&mut tables, &fx.journal_path).unwrap();
607
608 assert_eq!(
609 tables.get("users").unwrap().get("u1").unwrap().unwrap().get("nickname"),
610 None
611 );
612 let written = std::fs::read_to_string(&fx.users_path).unwrap();
613 assert!(!written.contains("nickname"), "{written}");
614 }
615
616 #[test]
617 fn a_read_through_the_unit_sees_its_own_uncommitted_put() {
618 let fx = fixture();
619 let tables = open_tables(&fx);
620
621 let mut unit = UnitOfWork::new();
622 unit.put("users", json!({ "id": "u1", "name": "Ania" }));
623
624 let seen = unit.get("users", "u1", tables.get("users").unwrap()).unwrap();
625 assert_eq!(seen.unwrap().get("name"), Some(&json!("Ania")));
626 assert!(tables.get("users").unwrap().get("u1").unwrap().is_none());
627 }
628
629 #[test]
632 fn a_page_through_the_unit_is_cut_after_the_staged_writes_joined_the_order() {
633 let fx = fixture();
634 let mut tables = open_tables(&fx);
635 let users = tables.get_mut("users").unwrap();
636 users.put(&json!({ "id": "u1", "name": "Bea" }), false).unwrap();
637 users.put(&json!({ "id": "u3", "name": "Cela" }), false).unwrap();
638 let users = tables.get("users").unwrap();
639
640 let mut unit = UnitOfWork::new();
641 unit.put("users", json!({ "id": "u2", "name": "Ala" }));
642
643 let spec = QueryBuilder::new()
644 .order_by("name", crate::query::planner::SortDirection::Asc)
645 .limit(2)
646 .spec();
647 let through_unit = unit.query("users", users, &spec).unwrap();
648
649 assert_eq!(through_unit.keys, vec!["u2".to_string(), "u1".to_string()]);
650 assert_eq!(
651 through_unit.matched, 3,
652 "the page is two wide, all three records matched"
653 );
654 }
655
656 fn name_eq(name: &str) -> QuerySpec {
657 QueryBuilder::new()
658 .r#where("name", ComparisonOp::Eq, json!(name))
659 .spec()
660 }
661
662 #[test]
663 fn a_query_through_the_unit_finds_an_uncommitted_put_the_table_itself_does_not_see_yet() {
664 let fx = fixture();
665 let tables = open_tables(&fx);
666 let users = tables.get("users").unwrap();
667
668 let mut unit = UnitOfWork::new();
669 unit.put("users", json!({ "id": "u1", "name": "Ania" }));
670
671 let spec = name_eq("Ania");
672 assert!(users.query(&spec).unwrap().keys.is_empty());
673
674 let through_unit = unit.query("users", users, &spec).unwrap();
675 assert_eq!(through_unit.keys, vec!["u1".to_string()]);
676 }
677
678 #[test]
679 fn a_query_through_the_unit_excludes_an_uncommitted_delete_the_table_itself_still_finds() {
680 let fx = fixture();
681 let mut tables = open_tables(&fx);
682 tables
683 .get_mut("users")
684 .unwrap()
685 .put(&json!({ "id": "u1", "name": "Ania" }), false)
686 .unwrap();
687 let users = tables.get("users").unwrap();
688
689 let mut unit = UnitOfWork::new();
690 unit.delete("users", "u1");
691
692 let spec = name_eq("Ania");
693 assert_eq!(users.query(&spec).unwrap().keys, vec!["u1".to_string()]);
694
695 let through_unit = unit.query("users", users, &spec).unwrap();
696 assert!(through_unit.keys.is_empty());
697 }
698
699 #[test]
700 fn a_query_through_the_unit_excludes_a_patch_that_makes_the_record_stop_matching() {
701 let fx = fixture();
702 let mut tables = open_tables(&fx);
703 tables
704 .get_mut("users")
705 .unwrap()
706 .put(&json!({ "id": "u1", "name": "Ania" }), false)
707 .unwrap();
708 let users = tables.get("users").unwrap();
709
710 let mut unit = UnitOfWork::new();
711 unit.patch("users", "u1", json!({ "name": "Zosia" }));
712
713 let spec = name_eq("Ania");
714 assert_eq!(users.query(&spec).unwrap().keys, vec!["u1".to_string()]);
715
716 let through_unit = unit.query("users", users, &spec).unwrap();
717 assert!(through_unit.keys.is_empty());
718 }
719
720 #[test]
721 fn an_unknown_table_fails_the_whole_unit_before_anything_is_staged_to_disk() {
722 let fx = fixture();
723 let mut tables = open_tables(&fx);
724
725 let mut unit = UnitOfWork::new();
726 unit.put("users", json!({ "id": "u1", "name": "Ania" }));
727 unit.put("ghost", json!({ "id": "x" }));
728
729 let error = unit.commit(&mut tables, &fx.journal_path).unwrap_err();
730 assert!(matches!(error, TxnError::UnknownTable(name) if name == "ghost"));
731 assert!(tables.get("users").unwrap().get("u1").unwrap().is_none());
732 assert!(!fx.journal_path.exists());
733 }
734
735 #[test]
736 fn an_invalid_op_in_one_table_rejects_the_whole_unit_with_nothing_written_anywhere() {
737 let fx = fixture();
738 let mut tables = open_tables(&fx);
739
740 let mut unit = UnitOfWork::new();
741 unit.put("users", json!({ "id": "u1", "name": "Ania" }));
742 unit.put("orders", json!({ "id": "o1", "userId": "u1" }));
744
745 let error = unit.commit(&mut tables, &fx.journal_path).unwrap_err();
746 assert!(matches!(error, TxnError::Table(name, _) if name == "orders"));
747 assert!(tables.get("users").unwrap().get("u1").unwrap().is_none());
748 assert!(tables.get("orders").unwrap().get("o1").unwrap().is_none());
749 assert!(!fx.journal_path.exists());
750 }
751
752 #[test]
753 fn a_refused_unit_keeps_its_staging_and_lands_once_the_cause_is_fixed() {
754 let fx = fixture();
755 let mut tables = open_tables(&fx);
756
757 let mut unit = UnitOfWork::new();
758 unit.put("users", json!({ "id": "u1", "name": "Ania" }));
759 unit.patch("orders", "o1", json!({ "total": 20 }));
761
762 let error = unit.commit(&mut tables, &fx.journal_path).unwrap_err();
763 assert!(matches!(error, TxnError::Table(name, _) if name == "orders"));
764 assert_eq!(unit.operations(), 2, "the refusal left the staging alone");
765 assert_eq!(unit.tables(), ["users".to_string(), "orders".to_string()]);
766 assert_eq!(
767 unit.get("users", "u1", tables.get("users").unwrap())
768 .unwrap()
769 .unwrap()
770 .get("name"),
771 Some(&json!("Ania")),
772 "and the unit still reads through its own staging"
773 );
774
775 tables
776 .get_mut("orders")
777 .unwrap()
778 .put(&json!({ "id": "o1", "userId": "u1", "total": 10 }), false)
779 .unwrap();
780
781 let result = unit.commit(&mut tables, &fx.journal_path).unwrap();
782 assert_eq!(result.operations, 2);
783 assert_eq!(
784 tables.get("users").unwrap().get("u1").unwrap().unwrap().get("name"),
785 Some(&json!("Ania"))
786 );
787 assert_eq!(
788 tables.get("orders").unwrap().get("o1").unwrap().unwrap().get("total"),
789 Some(&json!(20))
790 );
791 assert!(!fx.journal_path.exists());
792 }
793
794 fn people_schema() -> TableSchema {
795 parse_schema(&json!({
796 "schemaVersion": 1,
797 "table": "people",
798 "primaryKey": "id",
799 "fields": {
800 "id": { "type": "string" },
801 "email": { "type": "string", "required": true, "unique": true },
802 "bio": { "type": "string", "fulltext": true },
803 },
804 }))
805 .unwrap()
806 }
807
808 fn open_people(fx: &Fixture) -> HashMap<String, Table> {
809 let mut tables = HashMap::new();
810 let path = fx.users_path.with_file_name("people.jsonl");
811 tables.insert("people".to_string(), Table::open(people_schema(), &path).unwrap());
812 tables
813 }
814
815 fn is_unique_violation(error: &TxnError, holder: &str) -> bool {
816 matches!(
817 error,
818 TxnError::Table(name, TableError::Unique(violation))
819 if name == "people" && violation.field == "email" && violation.holder == holder
820 )
821 }
822
823 #[test]
824 fn two_puts_of_one_email_in_the_same_unit_are_refused_before_either_lands() {
825 let fx = fixture();
826 let mut tables = open_people(&fx);
827
828 let mut unit = UnitOfWork::new();
829 unit.put("people", json!({ "id": "p1", "email": "ala@example.com" }));
830 unit.put("people", json!({ "id": "p2", "email": "ala@example.com" }));
831
832 let error = unit.commit(&mut tables, &fx.journal_path).unwrap_err();
833 assert!(
834 is_unique_violation(&error, "p1"),
835 "the first op of the unit is the holder: {error}"
836 );
837 assert!(
838 tables.get("people").unwrap().get("p1").unwrap().is_none(),
839 "nothing of the unit landed"
840 );
841 assert!(tables.get("people").unwrap().get("p2").unwrap().is_none());
842 assert!(!fx.journal_path.exists(), "a refused prepare writes no journal");
843 }
844
845 #[test]
846 fn a_patch_that_duplicates_a_put_made_earlier_in_the_same_unit_is_refused_too() {
847 let fx = fixture();
848 let mut tables = open_people(&fx);
849 tables
850 .get_mut("people")
851 .unwrap()
852 .put(&json!({ "id": "p2", "email": "bea@example.com" }), false)
853 .unwrap();
854
855 let mut unit = UnitOfWork::new();
856 unit.put("people", json!({ "id": "p1", "email": "ala@example.com" }));
857 unit.patch("people", "p2", json!({ "email": "ala@example.com" }));
858
859 let error = unit.commit(&mut tables, &fx.journal_path).unwrap_err();
860 assert!(is_unique_violation(&error, "p1"), "{error}");
861 assert_eq!(
862 tables.get("people").unwrap().get("p2").unwrap().unwrap().get("email"),
863 Some(&json!("bea@example.com")),
864 "the committed record kept the address the refused patch would have taken"
865 );
866 }
867
868 #[test]
871 fn a_unit_may_hand_an_email_from_the_record_it_deletes_to_the_one_it_writes() {
872 let fx = fixture();
873 let mut tables = open_people(&fx);
874 tables
875 .get_mut("people")
876 .unwrap()
877 .put(&json!({ "id": "p1", "email": "ala@example.com" }), false)
878 .unwrap();
879
880 let mut unit = UnitOfWork::new();
881 unit.delete("people", "p1");
882 unit.put("people", json!({ "id": "p2", "email": "ala@example.com" }));
883
884 unit.commit(&mut tables, &fx.journal_path).unwrap();
885 assert!(tables.get("people").unwrap().get("p1").unwrap().is_none());
886 assert_eq!(
887 tables.get("people").unwrap().get("p2").unwrap().unwrap().get("email"),
888 Some(&json!("ala@example.com"))
889 );
890 }
891
892 #[test]
893 fn a_patch_merges_over_the_put_the_same_unit_made_of_a_key_the_log_does_not_hold_yet() {
894 let fx = fixture();
895 let mut tables = open_people(&fx);
896
897 let mut unit = UnitOfWork::new();
898 unit.put(
899 "people",
900 json!({ "id": "p1", "email": "ala@example.com", "bio": "backend" }),
901 );
902 unit.patch("people", "p1", json!({ "bio": "backend developer" }));
903
904 unit.commit(&mut tables, &fx.journal_path).unwrap();
905 let landed = tables.get("people").unwrap().get("p1").unwrap().unwrap();
906 assert_eq!(
907 landed.get("bio"),
908 Some(&json!("backend developer")),
909 "the patch won over the put"
910 );
911 assert_eq!(
912 landed.get("email"),
913 Some(&json!("ala@example.com")),
914 "a field the patch left alone survived the merge instead of being dropped"
915 );
916 }
917
918 #[test]
919 fn a_patch_of_a_key_the_same_unit_deleted_is_refused_as_absent() {
920 let fx = fixture();
921 let mut tables = open_people(&fx);
922 tables
923 .get_mut("people")
924 .unwrap()
925 .put(&json!({ "id": "p1", "email": "ala@example.com" }), false)
926 .unwrap();
927
928 let mut unit = UnitOfWork::new();
929 unit.delete("people", "p1");
930 unit.patch("people", "p1", json!({ "email": "bea@example.com" }));
931
932 let error = unit.commit(&mut tables, &fx.journal_path).unwrap_err();
933 assert!(
934 matches!(&error, TxnError::Table(name, TableError::NotFound(missing)) if name == "people" && missing.key == "p1"),
935 "the staged delete makes the key absent, it does not fall back to the committed record: {error}"
936 );
937 assert_eq!(
938 tables.get("people").unwrap().get("p1").unwrap().unwrap().get("email"),
939 Some(&json!("ala@example.com")),
940 "the refused unit left the committed record untouched"
941 );
942 }
943
944 #[test]
945 fn a_search_through_the_unit_leaves_out_a_staged_record_the_term_is_not_in() {
946 let fx = fixture();
947 let mut tables = open_people(&fx);
948 tables
949 .get_mut("people")
950 .unwrap()
951 .put(
952 &json!({ "id": "p1", "email": "ala@example.com", "bio": "backend developer" }),
953 false,
954 )
955 .unwrap();
956 let people = tables.get("people").unwrap();
957
958 let mut unit = UnitOfWork::new();
959 unit.put(
960 "people",
961 json!({ "id": "p2", "email": "bea@example.com", "bio": "frontend designer" }),
962 );
963
964 let backend = QueryBuilder::new().search("backend", SearchMode::All).spec();
965 let designer = QueryBuilder::new().search("designer", SearchMode::All).spec();
966
967 assert_eq!(
968 unit.query("people", people, &backend).unwrap().keys,
969 vec!["p1".to_string()],
970 "the staged record says nothing about backends"
971 );
972 assert_eq!(
973 unit.query("people", people, &designer).unwrap().keys,
974 vec!["p2".to_string()],
975 "and it is still found by a term it does carry"
976 );
977 assert!(
978 people.query(&designer).unwrap().keys.is_empty(),
979 "the committed state cannot see the staged record at all"
980 );
981 }
982
983 #[test]
984 fn a_search_through_the_unit_drops_a_record_a_staged_patch_takes_the_term_out_of() {
985 let fx = fixture();
986 let mut tables = open_people(&fx);
987 tables
988 .get_mut("people")
989 .unwrap()
990 .put(
991 &json!({ "id": "p1", "email": "ala@example.com", "bio": "backend developer" }),
992 false,
993 )
994 .unwrap();
995 let people = tables.get("people").unwrap();
996
997 let mut unit = UnitOfWork::new();
998 unit.patch("people", "p1", json!({ "bio": "frontend designer" }));
999
1000 let backend = QueryBuilder::new().search("backend", SearchMode::All).spec();
1001 assert_eq!(people.query(&backend).unwrap().keys, vec!["p1".to_string()]);
1002 assert!(
1003 unit.query("people", people, &backend).unwrap().keys.is_empty(),
1004 "the patch takes the term away, so the unit must stop answering with the key"
1005 );
1006 }
1007
1008 #[test]
1009 fn a_unit_answers_a_prefix_search_with_a_record_only_it_has_staged() {
1010 let fx = fixture();
1011 let tables = open_people(&fx);
1012 let people = tables.get("people").unwrap();
1013
1014 let mut unit = UnitOfWork::new();
1015 unit.put(
1016 "people",
1017 json!({ "id": "p1", "email": "ala@example.com", "bio": "Backend architecture" }),
1018 );
1019
1020 let hit = QueryBuilder::new().search("architec*", SearchMode::All).spec();
1021 let miss = QueryBuilder::new().search("architek* backend", SearchMode::All).spec();
1022
1023 assert_eq!(unit.query("people", people, &hit).unwrap().keys, vec!["p1".to_string()]);
1024 assert!(
1025 unit.query("people", people, &miss).unwrap().keys.is_empty(),
1026 "\"architek\" is no prefix of \"architecture\", and All wants every term"
1027 );
1028 }
1029
1030 #[test]
1031 fn a_unit_refused_for_a_bad_document_is_refused_again_because_staging_is_append_only() {
1032 let fx = fixture();
1033 let mut tables = open_tables(&fx);
1034
1035 let mut unit = UnitOfWork::new();
1036 unit.put("orders", json!({ "id": "o1", "userId": "u1" }));
1038 unit.commit(&mut tables, &fx.journal_path).unwrap_err();
1039
1040 unit.put("orders", json!({ "id": "o1", "userId": "u1", "total": 10 }));
1041
1042 let error = unit.commit(&mut tables, &fx.journal_path).unwrap_err();
1043 assert!(matches!(error, TxnError::Table(name, _) if name == "orders"));
1044 assert!(tables.get("orders").unwrap().get("o1").unwrap().is_none());
1045 assert!(!fx.journal_path.exists());
1046 }
1047
1048 #[test]
1049 fn a_unit_that_landed_refuses_a_second_commit_instead_of_writing_twice() {
1050 let fx = fixture();
1051 let mut tables = open_tables(&fx);
1052
1053 let mut unit = UnitOfWork::new();
1054 unit.put("orders", json!({ "id": "o1", "userId": "u1", "total": 10 }));
1055 unit.commit(&mut tables, &fx.journal_path).unwrap();
1056 let after_first = tables.get("orders").unwrap().current_seq();
1057
1058 let error = unit.commit(&mut tables, &fx.journal_path).unwrap_err();
1059 assert!(matches!(error, TxnError::AlreadyCommitted));
1060 assert_eq!(
1061 tables.get("orders").unwrap().current_seq(),
1062 after_first,
1063 "the refused second attempt did not even reserve a seq"
1064 );
1065 }
1066
1067 #[test]
1068 fn nothing_to_recover_when_no_journal_was_ever_written() {
1069 let fx = fixture();
1070 let mut tables = open_tables(&fx);
1071
1072 let recovered = recover_incomplete(&fx.journal_path, &mut tables).unwrap();
1073 assert!(recovered.is_none());
1074 assert!(tables.get("users").unwrap().get("u1").unwrap().is_none());
1075 }
1076
1077 #[test]
1078 fn recover_incomplete_finishes_a_journal_left_by_a_crash_between_journal_and_write() {
1079 let fx = fixture();
1080 {
1081 let mut users = Table::open(users_schema(), &fx.users_path).unwrap();
1082 let mut orders = Table::open(orders_schema(), &fx.orders_path).unwrap();
1083
1084 let user_entries = users
1085 .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Ania" }))])
1086 .unwrap();
1087 let order_entries = orders
1088 .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "o1", "userId": "u1", "total": 10 }))])
1089 .unwrap();
1090
1091 let mut entries = Vec::new();
1092 for entry in &user_entries {
1093 entries.push(JournalEntry {
1094 t: "users".into(),
1095 s: entry.seq(),
1096 l: entry.line().to_string(),
1097 });
1098 }
1099 for entry in &order_entries {
1100 entries.push(JournalEntry {
1101 t: "orders".into(),
1102 s: entry.seq(),
1103 l: entry.line().to_string(),
1104 });
1105 }
1106
1107 journal::write_journal(
1108 &fx.journal_path,
1109 &Journal {
1110 header: JournalHeader {
1111 journal_version: JOURNAL_VERSION,
1112 kind: "unit".into(),
1113 id: "crash-test".into(),
1114 started_at: 0,
1115 tables: vec!["users".into(), "orders".into()],
1116 },
1117 entries,
1118 },
1119 )
1120 .unwrap();
1121 }
1124
1125 assert!(fx.journal_path.exists());
1126 let mut tables = open_tables(&fx);
1127 assert!(tables.get("users").unwrap().get("u1").unwrap().is_none());
1128 assert!(tables.get("orders").unwrap().get("o1").unwrap().is_none());
1129
1130 let result = recover_incomplete(&fx.journal_path, &mut tables)
1131 .unwrap()
1132 .expect("journal must be found");
1133 assert_eq!(result.id, "crash-test");
1134 assert_eq!(result.operations, 2);
1135 assert!(!fx.journal_path.exists());
1136
1137 assert_eq!(
1138 tables.get("users").unwrap().get("u1").unwrap().unwrap().get("name"),
1139 Some(&json!("Ania"))
1140 );
1141 assert_eq!(
1142 tables.get("orders").unwrap().get("o1").unwrap().unwrap().get("total"),
1143 Some(&json!(10))
1144 );
1145 }
1146
1147 #[test]
1148 fn recovering_twice_is_a_no_op_the_second_time() {
1149 let fx = fixture();
1150 {
1151 let mut users = Table::open(users_schema(), &fx.users_path).unwrap();
1152 let entries = users
1153 .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Ania" }))])
1154 .unwrap();
1155 let mut journal_entries = Vec::new();
1156 for entry in &entries {
1157 journal_entries.push(JournalEntry {
1158 t: "users".into(),
1159 s: entry.seq(),
1160 l: entry.line().to_string(),
1161 });
1162 }
1163 journal::write_journal(
1164 &fx.journal_path,
1165 &Journal {
1166 header: JournalHeader {
1167 journal_version: JOURNAL_VERSION,
1168 kind: "unit".into(),
1169 id: "once".into(),
1170 started_at: 0,
1171 tables: vec!["users".into()],
1172 },
1173 entries: journal_entries,
1174 },
1175 )
1176 .unwrap();
1177 }
1178
1179 let mut tables = open_tables(&fx);
1180 recover_incomplete(&fx.journal_path, &mut tables)
1181 .unwrap()
1182 .expect("first recovery finds it");
1183 let second = recover_incomplete(&fx.journal_path, &mut tables).unwrap();
1184 assert!(second.is_none());
1185 assert_eq!(
1186 tables.get("users").unwrap().get("u1").unwrap().unwrap().get("name"),
1187 Some(&json!("Ania"))
1188 );
1189 }
1190
1191 fn audit_schema() -> TableSchema {
1192 parse_schema(&json!({
1193 "schemaVersion": 1,
1194 "table": "audit",
1195 "primaryKey": "id",
1196 "fields": {
1197 "id": { "type": "string" },
1198 "beforeName": { "type": "string" },
1199 "afterName": { "type": "string" },
1200 },
1201 }))
1202 .unwrap()
1203 }
1204
1205 fn insert_audit_trigger() -> crate::catalog::TriggerEntry {
1206 crate::catalog::TriggerEntry {
1207 name: "users_insert_audit".into(),
1208 table: "users".into(),
1209 event: TriggerEvent::Insert,
1210 effect: crate::catalog::TriggerEffect {
1211 table: "audit".into(),
1212 fields: vec![
1213 crate::catalog::TriggerFieldMapping {
1214 target: "id".into(),
1215 source: crate::catalog::TriggerValueSource::NewField("id".into()),
1216 },
1217 crate::catalog::TriggerFieldMapping {
1218 target: "afterName".into(),
1219 source: crate::catalog::TriggerValueSource::NewField("name".into()),
1220 },
1221 ],
1222 },
1223 }
1224 }
1225
1226 fn update_audit_trigger() -> crate::catalog::TriggerEntry {
1227 crate::catalog::TriggerEntry {
1228 name: "users_update_audit".into(),
1229 table: "users".into(),
1230 event: TriggerEvent::Update,
1231 effect: crate::catalog::TriggerEffect {
1232 table: "audit".into(),
1233 fields: vec![
1234 crate::catalog::TriggerFieldMapping {
1235 target: "id".into(),
1236 source: crate::catalog::TriggerValueSource::NewField("id".into()),
1237 },
1238 crate::catalog::TriggerFieldMapping {
1239 target: "beforeName".into(),
1240 source: crate::catalog::TriggerValueSource::OldField("name".into()),
1241 },
1242 crate::catalog::TriggerFieldMapping {
1243 target: "afterName".into(),
1244 source: crate::catalog::TriggerValueSource::NewField("name".into()),
1245 },
1246 ],
1247 },
1248 }
1249 }
1250
1251 fn delete_audit_trigger() -> crate::catalog::TriggerEntry {
1252 crate::catalog::TriggerEntry {
1253 name: "users_delete_audit".into(),
1254 table: "users".into(),
1255 event: TriggerEvent::Delete,
1256 effect: crate::catalog::TriggerEffect {
1257 table: "audit".into(),
1258 fields: vec![
1259 crate::catalog::TriggerFieldMapping {
1260 target: "id".into(),
1261 source: crate::catalog::TriggerValueSource::OldField("id".into()),
1262 },
1263 crate::catalog::TriggerFieldMapping {
1264 target: "beforeName".into(),
1265 source: crate::catalog::TriggerValueSource::OldField("name".into()),
1266 },
1267 ],
1268 },
1269 }
1270 }
1271
1272 #[test]
1273 fn a_delete_trigger_sees_the_record_the_same_unit_put_moments_earlier() {
1274 let dir = tempdir().unwrap();
1275 let mut tables = HashMap::new();
1276 tables.insert(
1277 "users".to_string(),
1278 Table::open(users_schema(), dir.path().join("users.jsonl")).unwrap(),
1279 );
1280 tables.insert(
1281 "audit".to_string(),
1282 Table::open(audit_schema(), dir.path().join("audit.jsonl")).unwrap(),
1283 );
1284 tables
1285 .get_mut("users")
1286 .unwrap()
1287 .set_triggers(vec![delete_audit_trigger()]);
1288 let journal_path = dir.path().join(super::journal::JOURNAL_FILE);
1289
1290 let mut unit = UnitOfWork::new();
1291 unit.put("users", json!({ "id": "u1", "name": "Ania" }));
1292 unit.delete("users", "u1");
1293 unit.commit(&mut tables, &journal_path).unwrap();
1294
1295 assert!(tables.get("users").unwrap().get("u1").unwrap().is_none());
1296 let audit = tables
1297 .get("audit")
1298 .unwrap()
1299 .get("u1")
1300 .unwrap()
1301 .expect("the delete fired the trigger");
1302 assert_eq!(audit.get("beforeName"), Some(&json!("Ania")));
1303 }
1304
1305 #[test]
1306 fn a_put_over_a_key_the_same_unit_deleted_fires_insert_not_update() {
1307 let dir = tempdir().unwrap();
1308 let mut tables = HashMap::new();
1309 tables.insert(
1310 "users".to_string(),
1311 Table::open(users_schema(), dir.path().join("users.jsonl")).unwrap(),
1312 );
1313 tables.insert(
1314 "audit".to_string(),
1315 Table::open(audit_schema(), dir.path().join("audit.jsonl")).unwrap(),
1316 );
1317 let journal_path = dir.path().join(super::journal::JOURNAL_FILE);
1318
1319 let mut seed = UnitOfWork::new();
1320 seed.put("users", json!({ "id": "u1", "name": "Ania" }));
1321 seed.commit(&mut tables, &journal_path).unwrap();
1322
1323 tables
1324 .get_mut("users")
1325 .unwrap()
1326 .set_triggers(vec![insert_audit_trigger(), update_audit_trigger()]);
1327
1328 let mut unit = UnitOfWork::new();
1329 unit.delete("users", "u1");
1330 unit.put("users", json!({ "id": "u1", "name": "Zosia" }));
1331 unit.commit(&mut tables, &journal_path).unwrap();
1332
1333 let audit = tables
1334 .get("audit")
1335 .unwrap()
1336 .get("u1")
1337 .unwrap()
1338 .expect("the put fired a trigger");
1339 assert_eq!(audit.get("afterName"), Some(&json!("Zosia")));
1340 assert_eq!(
1341 audit.get("beforeName"),
1342 None,
1343 "the key was dead when the put ran, so this is an insert"
1344 );
1345 }
1346
1347 #[test]
1348 fn a_cross_table_insert_trigger_effect_lands_in_the_same_unit_of_work() {
1349 let dir = tempdir().unwrap();
1350 let mut tables = HashMap::new();
1351 tables.insert(
1352 "users".to_string(),
1353 Table::open(users_schema(), dir.path().join("users.jsonl")).unwrap(),
1354 );
1355 tables.insert(
1356 "audit".to_string(),
1357 Table::open(audit_schema(), dir.path().join("audit.jsonl")).unwrap(),
1358 );
1359 tables
1360 .get_mut("users")
1361 .unwrap()
1362 .set_triggers(vec![insert_audit_trigger()]);
1363 let journal_path = dir.path().join(super::journal::JOURNAL_FILE);
1364
1365 let mut unit = UnitOfWork::new();
1366 unit.put("users", json!({ "id": "u1", "name": "Ania" }));
1367 let result = unit.commit(&mut tables, &journal_path).unwrap();
1368
1369 assert!(result.tables.contains(&"audit".to_string()));
1370 assert!(!journal_path.exists());
1371 assert_eq!(
1372 tables
1373 .get("audit")
1374 .unwrap()
1375 .get("u1")
1376 .unwrap()
1377 .unwrap()
1378 .get("afterName"),
1379 Some(&json!("Ania"))
1380 );
1381 }
1382
1383 #[test]
1384 fn an_update_trigger_sees_old_and_new_of_the_same_patch_at_once() {
1385 let dir = tempdir().unwrap();
1386 let mut tables = HashMap::new();
1387 tables.insert(
1388 "users".to_string(),
1389 Table::open(users_schema(), dir.path().join("users.jsonl")).unwrap(),
1390 );
1391 tables.insert(
1392 "audit".to_string(),
1393 Table::open(audit_schema(), dir.path().join("audit.jsonl")).unwrap(),
1394 );
1395 let journal_path = dir.path().join(super::journal::JOURNAL_FILE);
1396
1397 let mut seed = UnitOfWork::new();
1398 seed.put("users", json!({ "id": "u1", "name": "Ania" }));
1399 seed.commit(&mut tables, &journal_path).unwrap();
1400
1401 tables
1402 .get_mut("users")
1403 .unwrap()
1404 .set_triggers(vec![update_audit_trigger()]);
1405
1406 let mut unit = UnitOfWork::new();
1407 unit.patch("users", "u1", json!({ "name": "Anna" }));
1408 unit.commit(&mut tables, &journal_path).unwrap();
1409
1410 let audit = tables.get("audit").unwrap().get("u1").unwrap().unwrap();
1411 assert_eq!(audit.get("beforeName"), Some(&json!("Ania")));
1412 assert_eq!(audit.get("afterName"), Some(&json!("Anna")));
1413 }
1414
1415 fn seeded_people(fx: &Fixture) -> HashMap<String, Table> {
1416 let mut tables = open_people(fx);
1417 let people = tables.get_mut("people").unwrap();
1418 people
1419 .put(
1420 &json!({ "id": "p1", "email": "ala@example.com", "bio": "backend developer" }),
1421 false,
1422 )
1423 .unwrap();
1424 people
1425 .put(
1426 &json!({ "id": "p2", "email": "bea@example.com", "bio": "frontend designer" }),
1427 false,
1428 )
1429 .unwrap();
1430 tables
1431 }
1432
1433 fn overlay_of(unit: &UnitOfWork, people: &Table, spec: &QuerySpec) -> PlanOverlay {
1434 unit.query("people", people, spec)
1435 .unwrap()
1436 .plan
1437 .overlay
1438 .expect("a unit query always plans through an overlay")
1439 }
1440
1441 #[test]
1442 fn a_staged_put_that_matches_counts_as_one_staged_candidate_and_suppresses_nothing() {
1443 let fx = fixture();
1444 let tables = seeded_people(&fx);
1445 let people = tables.get("people").unwrap();
1446
1447 let mut unit = UnitOfWork::new();
1448 unit.put(
1449 "people",
1450 json!({ "id": "p3", "email": "cela@example.com", "bio": "backend developer" }),
1451 );
1452
1453 let spec = QueryBuilder::new()
1454 .r#where("email", ComparisonOp::Eq, json!("cela@example.com"))
1455 .spec();
1456 let overlay = overlay_of(&unit, people, &spec);
1457
1458 assert_eq!(overlay.staged, 1, "the staged record joined the candidates");
1459 assert_eq!(
1460 overlay.suppressed, 0,
1461 "no committed candidate was taken out — the index held none"
1462 );
1463 assert!(!overlay.rescored, "no search ran, so nothing was scored at all");
1464 assert_eq!(
1465 overlay.strategy,
1466 OverlayStrategy::Exact,
1467 "email is indexed, so the overlay narrowed on it"
1468 );
1469 assert_eq!(overlay.size, 1);
1470 assert_eq!(
1471 unit.query("people", people, &spec).unwrap().keys,
1472 vec!["p3".to_string()]
1473 );
1474 }
1475
1476 #[test]
1477 fn a_staged_delete_suppresses_the_committed_candidate_the_index_still_holds() {
1478 let fx = fixture();
1479 let tables = seeded_people(&fx);
1480 let people = tables.get("people").unwrap();
1481
1482 let mut unit = UnitOfWork::new();
1483 unit.delete("people", "p1");
1484
1485 let spec = QueryBuilder::new()
1486 .r#where("email", ComparisonOp::Eq, json!("ala@example.com"))
1487 .spec();
1488 let overlay = overlay_of(&unit, people, &spec);
1489
1490 assert_eq!(
1491 overlay.suppressed, 1,
1492 "the committed index still holds p1, the overlay takes it back out"
1493 );
1494 assert_eq!(overlay.staged, 0, "a delete stages no record to put back in");
1495 assert!(!overlay.rescored);
1496 assert_eq!(overlay.size, 0, "a deleted key is not a staged record");
1497 assert!(unit.query("people", people, &spec).unwrap().keys.is_empty());
1498 }
1499
1500 #[test]
1501 fn a_staged_patch_suppresses_the_committed_candidate_and_re_stages_it_in_its_new_state() {
1502 let fx = fixture();
1503 let tables = seeded_people(&fx);
1504 let people = tables.get("people").unwrap();
1505
1506 let mut unit = UnitOfWork::new();
1507 unit.patch("people", "p1", json!({ "email": "nowa@example.com" }));
1508
1509 let by_old = QueryBuilder::new()
1510 .r#where("email", ComparisonOp::Eq, json!("ala@example.com"))
1511 .spec();
1512 let by_new = QueryBuilder::new()
1513 .r#where("email", ComparisonOp::Eq, json!("nowa@example.com"))
1514 .spec();
1515
1516 let old = overlay_of(&unit, people, &by_old);
1517 assert_eq!(
1518 old.suppressed, 1,
1519 "the committed index still answers the old email, the overlay overrides it"
1520 );
1521 assert_eq!(old.staged, 0, "and the patched record no longer carries that value");
1522 assert!(unit.query("people", people, &by_old).unwrap().keys.is_empty());
1523
1524 let new = overlay_of(&unit, people, &by_new);
1525 assert_eq!(
1526 new.staged, 1,
1527 "the overlay index answers a value the committed one never had"
1528 );
1529 assert_eq!(new.suppressed, 0);
1530 assert_eq!(
1531 unit.query("people", people, &by_new).unwrap().keys,
1532 vec!["p1".to_string()]
1533 );
1534 }
1535
1536 #[test]
1537 fn a_search_through_a_unit_is_rescored_over_the_corpus_the_unit_will_leave() {
1538 let fx = fixture();
1539 let tables = seeded_people(&fx);
1540 let people = tables.get("people").unwrap();
1541
1542 let mut unit = UnitOfWork::new();
1543 unit.put(
1544 "people",
1545 json!({ "id": "p3", "email": "cela@example.com", "bio": "backend architect" }),
1546 );
1547 unit.delete("people", "p1");
1548
1549 let spec = QueryBuilder::new().search("backend", SearchMode::All).spec();
1550 let overlay = overlay_of(&unit, people, &spec);
1551
1552 assert!(
1553 overlay.rescored,
1554 "the BM25 statistics were recomputed with p3 spliced in and p1 hidden"
1555 );
1556 assert_eq!(
1557 overlay.strategy,
1558 OverlayStrategy::Fulltext,
1559 "the staged half was filtered by the search text"
1560 );
1561 assert_eq!(
1562 overlay.staged, 1,
1563 "only p3 scores on \"backend\" among the staged records"
1564 );
1565 assert_eq!(
1566 overlay.suppressed, 1,
1567 "p3 scored, so it entered the committed path's key set and the overlay took it back for itself"
1568 );
1569 assert_eq!(
1570 unit.query("people", people, &spec).unwrap().keys,
1571 vec!["p3".to_string()],
1572 "the committed p1 is staged deleted, so only the staged p3 is left"
1573 );
1574 assert_eq!(
1575 people.query(&spec).unwrap().keys,
1576 vec!["p1".to_string()],
1577 "the committed state was never rescored and never saw p3"
1578 );
1579 }
1580
1581 #[test]
1582 fn a_search_without_an_overlay_index_falls_back_to_checking_every_staged_record() {
1583 let fx = fixture();
1584 let tables = seeded_people(&fx);
1585 let people = tables.get("people").unwrap();
1586
1587 let mut unit = UnitOfWork::new();
1588 unit.put(
1589 "people",
1590 json!({ "id": "p3", "email": "cela@example.com", "bio": "backend architect" }),
1591 );
1592
1593 let spec = QueryBuilder::new()
1594 .r#where("bio", ComparisonOp::Contains, json!("architect"))
1595 .spec();
1596 let overlay = overlay_of(&unit, people, &spec);
1597
1598 assert_eq!(
1599 overlay.strategy,
1600 OverlayStrategy::Scan,
1601 "no overlay index answers a `contains`"
1602 );
1603 assert_eq!(overlay.staged, 1, "so every staged record is a candidate");
1604 assert!(!overlay.rescored);
1605 }
1606
1607 #[test]
1608 fn a_unit_with_nothing_staged_still_reports_an_empty_overlay() {
1609 let fx = fixture();
1610 let tables = seeded_people(&fx);
1611 let people = tables.get("people").unwrap();
1612
1613 let spec = QueryBuilder::new()
1614 .r#where("email", ComparisonOp::Eq, json!("ala@example.com"))
1615 .spec();
1616 let overlay = overlay_of(&UnitOfWork::new(), people, &spec);
1617
1618 assert_eq!(
1619 overlay,
1620 PlanOverlay {
1621 staged: 0,
1622 suppressed: 0,
1623 rescored: false,
1624 strategy: OverlayStrategy::Exact,
1625 size: 0
1626 },
1627 "an empty unit changes nothing, and says so rather than leaving the plan silent"
1628 );
1629 assert!(
1630 people.query(&spec).unwrap().plan.overlay.is_none(),
1631 "a plain table query has no overlay at all"
1632 );
1633 }
1634}