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