Skip to main content

hayson_engine/txn/
unit_of_work.rs

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/// The process half of a unit id. Same platform split, and the same trap, as `lock::platform::pid`
19/// — kept local because `txn` does not depend on `lock`.
20///
21/// NOTE: std::process::id() PANICS on wasm32-wasip1 ("no pids on this platform") — never call it.
22#[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/// A batch of writes across several tables that lands whole or not at all (ADR 0012). Operations
82/// are collected with `put`/`patch`/`delete` and nothing touches a table until `commit()`, which
83/// runs the six steps documented in `docs/architecture.md`: resolve, barrier, prepare, journal,
84/// write, cleanup. `&mut Table` borrows held for the call are the barrier — no separate write
85/// queue to drain, since nothing else can hold those tables mutably at the same time.
86///
87/// `commit()` borrows the unit rather than consuming it, so a refused commit can be retried on the
88/// same unit once its cause is fixed — the same contract as the TS `UnitOfWork`. Staging is
89/// append-only in both engines: fixing a refusal means fixing what it named, not unstaging an op.
90#[derive(Default)]
91pub struct UnitOfWork {
92    order: Vec<String>,
93    staged: HashMap<String, Vec<TxnOp>>,
94    operations: usize,
95    committed: bool,
96}
97
98/// Everything [`UnitOfWork::prepare`] worked out, carried from there to [`UnitOfWork::journal`]
99/// and on to `apply_prepared`. Owns its lines, so it can be held across the release of whatever
100/// lock guarded the tables it was prepared against — but only if that release keeps the caller the
101/// sole writer, since the `_seq` numbers inside it are already burned and a write that slipped in
102/// between would land ahead of them in the log.
103pub struct PreparedUnit {
104    id: String,
105    touched: Vec<String>,
106    entries: Vec<(String, Vec<PreparedTxnEntry>)>,
107    operations: usize,
108}
109
110impl PreparedUnit {
111    /// True when the unit had nothing staged: no journal is written and no table is touched.
112    pub fn is_empty(&self) -> bool {
113        self.operations == 0
114    }
115
116    /// The tables this unit will write to, its own in staging order first, then any a trigger
117    /// effect added.
118    pub fn tables(&self) -> &[String] {
119        &self.touched
120    }
121}
122
123/// What a key would read as once this unit lands, computed against `table`'s committed state plus
124/// the ops staged so far for it (ADR-3h3r).
125pub 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    /// Reads a key through the unit: a value staged for `table` since the unit started, falling
166    /// back to `table`'s committed state for a key the unit does not touch. `put` and `patch` ops
167    /// are applied in staging order, `patch` merging over whatever the unit has staged so far (or
168    /// the committed record, on the first touch) exactly as `Table::patch` would.
169    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    /// Runs `spec` as the unit will leave `table`: the ops staged for `table_name` so far are
190    /// resolved into a `StagedIndex`, the query is planned over the committed candidates the
191    /// overlay has not taken over plus the staged records that can still match, and the answer is
192    /// tested against staged records and committed ones alike. A `search` is scored over the
193    /// overlaid corpus, so a staged record joins the ranking and not merely the result set.
194    ///
195    /// The plan carries a `PlanOverlay` reporting what the staged half contributed — how many
196    /// staged records joined the candidates, how many committed ones the overlay suppressed, and
197    /// whether the scores behind them were recomputed.
198    pub fn query(&self, table_name: &str, table: &Table, spec: &QuerySpec) -> Result<QueryResult, TableError> {
199        // NOTE: a unit with nothing staged still plans through an empty overlay, so `plan.overlay` is
200        // there for every query a unit answers — a caller reads it to tell a unit query from a plain one.
201        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    /// Runs every staged operation, or none of them, across `tables`.
206    ///
207    /// 1. resolve — an op naming a table missing from `tables` fails before anything else runs;
208    /// 2. barrier — the `&mut Table` borrows this call holds for its whole body;
209    /// 3. prepare — [`Table::prepare_txn_ops`] on every table, in staging order; the first
210    ///    rejected op fails the call with nothing appended anywhere;
211    /// 4. journal — every prepared line, across every table, written to `journal_path` in one
212    ///    `atomic::write_atomic` call;
213    /// 5. write — [`Table::apply_txn_entries`] per table, in staging order;
214    /// 6. cleanup — the journal file removed.
215    ///
216    /// A refusal raised by steps 1–3 leaves the unit live and fully staged, so the caller can fix
217    /// whatever the refusal named and call `commit` again on the same unit — nothing was written,
218    /// and only monotonic `_seq` counters were burned. From step 4 on the unit is spent whatever
219    /// happens, because a journal that reached the disk is finished by `recover_incomplete`, and a
220    /// second run of the same operations would apply them twice.
221    ///
222    /// Steps 1–3, 4 and 5–6 are also callable one at a time as [`UnitOfWork::prepare`],
223    /// [`UnitOfWork::journal`] and `apply_prepared`; this is those three back to back. A caller
224    /// that holds the tables behind a lock uses the split so it can let go of that lock across
225    /// step 4, which touches no table — see the concurrency section of `docs/architecture.md`.
226    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    /// Steps 1–3 of [`UnitOfWork::commit`]: resolve, prepare, and the triggers the prepared
237    /// entries fire. Mutates only the `_seq` counters of the tables it touches; nothing is
238    /// appended, no registry moves and no file is written, so a refusal here still leaves the unit
239    /// staged and retriable.
240    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    /// Step 4 of [`UnitOfWork::commit`]: every prepared line, across every table, written to
288    /// `journal_path` and fsynced in one `atomic::write_atomic` call. Reads no table and mutates
289    /// none, which is what lets a caller run it without the lock that guards them — the writes are
290    /// still invisible to a reader at this point, because nothing has been applied yet.
291    ///
292    /// Retires the unit: from the moment the journal is on disk the operations land whether this
293    /// process finishes them or the next `open()` does, so a second run would apply them twice.
294    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        // NOTE: spent from here on — a journal that lands is finished by recover_incomplete.
314        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    /// Computes every trigger effect the ops just prepared for `prepared` fire, `prepare_txn_ops`-
332    /// validated against its own effect table but not yet appended anywhere — folded into the same
333    /// `prepared`/journal/apply pass by the caller, so the effect lands in the very same unit of
334    /// work as the write that fired it. Reads triggers off `prepared`'s ops only, never off an
335    /// effect entry this same call produced, so v1's declared "no chaining" holds by construction.
336    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
380/// Steps 5–6 of [`UnitOfWork::commit`]: every prepared entry appended, table by table in staging
381/// order, and the journal removed once they all are. `tables` must be the very map `prepared` was
382/// prepared against — the entries carry the `_seq` numbers taken from it.
383///
384/// This is the step a reader must not see half of, so a caller that split the commit up holds its
385/// tables exclusively for the whole of it.
386pub 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
420/// Finishes an unfinished journal found at `journal_path`, if any — the recovery half of
421/// `commit()`'s step 4/5 split. `tables` must hold every table the journal names; a name it
422/// cannot resolve aborts recovery without touching the journal, so the next `open()` retries it
423/// rather than losing the record of what still needs finishing.
424pub 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    /// The staged record sorts first, so it can only reach a page of two if the page was cut after
609    /// the overlay corrected the baseline — cutting first would have paged the committed two alone.
610    #[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        // "total" is required by the orders schema — this op is invalid.
722        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        // "o1" is not in orders yet, so this patch cannot be prepared.
739        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    /// The committed index still names `p1` as the holder while the unit is being prepared, but the
848    /// same unit takes `p1` away first — so the value is free by the time anything lands.
849    #[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        // "total" is required by the orders schema — no later op can take this one back.
1016        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            // Process dies here: prepare ran (users/orders in-memory seq is bumped and the journal is
1101            // durable), but apply_txn_entries was never called on either table.
1102        }
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}