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