1use std::borrow::Cow;
2use std::collections::{BTreeMap, HashMap, HashSet};
3use std::fmt;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::time::{Duration, Instant};
8
9use serde_json::Value;
10
11use crate::catalog::{
12 CatalogCompaction, CatalogSyncMode, CatalogValidation, ExpandedIndex, LinkParsing, TriggerEntry, TriggerEvent,
13};
14use crate::checkpoint::{
15 remove_snapshot, restore_or_rebuild_set_with_declared, write_snapshot_set, RestoreOutcome, Restored,
16};
17use crate::crypto::{
18 assert_carried_seals, cypher_fields_of, envelope_key_id, open_record, seal_record, FieldCipher, MissingKeyPolicy,
19};
20use crate::errors::{
21 HaysonCipherError, HaysonNotFoundError, HaysonPartitionError, HaysonPendingConflictError, HaysonSchemaError,
22 HaysonUniqueViolationError, HaysonValidationError,
23};
24use crate::query::aggregate::{
25 execute_aggregate, execute_grouped_aggregate, AggregateResult, AggregateSpec, GroupedAggregateResult,
26};
27use crate::query::planner::{
28 execute_access, execute_query, plan_access_overlaid, LogSetRecordSource, QueryOverlay, QueryResult, QuerySpec,
29 RecordSource, RecordSourceError,
30};
31use crate::queue::write_queue::{assert_partition_stable, assert_unique_in_unit, UnitShadow};
32use crate::queue::{StageError, WriteOutcome, WriteQueue};
33use crate::registry::fulltext_index::CorpusDelta;
34use crate::registry::table_registry::TableRegistry;
35use crate::schema::{
36 broken_links, is_partitioned, partition_of, physical_path_of, primary_key_of, undeclared_fields, validate_record,
37 without_undeclared, FieldType, HaysonRecord, TableSchema, ValidateOptions, UNPARTITIONED,
38};
39use crate::storage::{
40 ensure_partitioned, parse_entry, partition_names_in, serialize_entry, LogEntry, LogFile, LogMeta, LogOp, LogProbe,
41 LogSet, ScanResult, Slot,
42};
43
44use super::cache::{CachePolicy, RecordCache};
45use super::trigger::{effect_document, matching, TriggerFiring};
46
47#[derive(Debug)]
48pub enum TableError {
49 Validation(HaysonValidationError),
50 Unique(HaysonUniqueViolationError),
51 Conflict(HaysonPendingConflictError),
52 NotFound(HaysonNotFoundError),
53 Schema(HaysonSchemaError),
54 Partition(HaysonPartitionError),
55 Cipher(Box<HaysonCipherError>),
58 Io(io::Error),
59}
60
61impl fmt::Display for TableError {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 TableError::Validation(e) => e.fmt(f),
65 TableError::Unique(e) => e.fmt(f),
66 TableError::Conflict(e) => e.fmt(f),
67 TableError::NotFound(e) => e.fmt(f),
68 TableError::Schema(e) => e.fmt(f),
69 TableError::Partition(e) => e.fmt(f),
70 TableError::Cipher(e) => e.fmt(f),
71 TableError::Io(e) => e.fmt(f),
72 }
73 }
74}
75
76impl std::error::Error for TableError {}
77
78impl From<RecordSourceError> for TableError {
79 fn from(value: RecordSourceError) -> Self {
80 match value {
81 RecordSourceError::Cipher(error) => TableError::Cipher(error),
82 RecordSourceError::Io(error) => TableError::Io(error),
83 }
84 }
85}
86
87impl From<StageError> for TableError {
88 fn from(value: StageError) -> Self {
89 match value {
90 StageError::Validation(e) => TableError::Validation(e),
91 StageError::Unique(e) => TableError::Unique(e),
92 StageError::Conflict(e) => TableError::Conflict(e),
93 StageError::Partition(e) => TableError::Partition(e),
94 }
95 }
96}
97
98impl From<io::Error> for TableError {
99 fn from(value: io::Error) -> Self {
100 TableError::Io(value)
101 }
102}
103
104impl From<HaysonValidationError> for TableError {
105 fn from(value: HaysonValidationError) -> Self {
106 TableError::Validation(value)
107 }
108}
109
110impl From<HaysonPendingConflictError> for TableError {
111 fn from(value: HaysonPendingConflictError) -> Self {
112 TableError::Conflict(value)
113 }
114}
115
116impl From<HaysonUniqueViolationError> for TableError {
117 fn from(value: HaysonUniqueViolationError) -> Self {
118 TableError::Unique(value)
119 }
120}
121
122impl From<HaysonPartitionError> for TableError {
123 fn from(value: HaysonPartitionError) -> Self {
124 TableError::Partition(value)
125 }
126}
127
128impl From<HaysonCipherError> for TableError {
129 fn from(value: HaysonCipherError) -> Self {
130 TableError::Cipher(Box::new(value))
131 }
132}
133
134impl From<TableError> for io::Error {
139 fn from(value: TableError) -> Self {
140 match value {
141 TableError::Io(error) => error,
142 other => io::Error::other(other.to_string()),
143 }
144 }
145}
146
147impl From<crate::queue::CommitError> for TableError {
148 fn from(value: crate::queue::CommitError) -> Self {
149 match value {
150 crate::queue::CommitError::Io(error) => TableError::Io(error),
151 crate::queue::CommitError::Cipher(error) => TableError::Cipher(Box::new(error)),
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct BrokenLink {
161 pub field: String,
162 pub path: String,
163}
164
165pub struct PutOutcome {
178 pub record: HaysonRecord,
179 pub warnings: Vec<BrokenLink>,
180}
181
182impl PutOutcome {
183 fn into_record(self) -> HaysonRecord {
184 self.record
185 }
186}
187
188impl From<PutOutcome> for HaysonRecord {
189 fn from(outcome: PutOutcome) -> Self {
190 outcome.record
191 }
192}
193
194enum RotationVerdict {
196 Current,
198 Rotate,
200 Missing(String),
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
207pub struct RotationResult {
208 pub rotated: usize,
210 pub already_current: usize,
213 pub skipped: usize,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum RefreshOutcome {
224 Unchanged,
227 CaughtUp { entries: usize },
230 Reloaded,
235}
236
237impl RefreshOutcome {
238 pub fn changed(self) -> bool {
240 self != RefreshOutcome::Unchanged
241 }
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub struct FollowPolicy {
257 pub interval: Duration,
258}
259
260impl Default for FollowPolicy {
261 fn default() -> Self {
262 FollowPolicy {
263 interval: Duration::ZERO,
264 }
265 }
266}
267
268struct StagedWrite {
276 id: u64,
279 record: Option<HaysonRecord>,
281 old: Option<HaysonRecord>,
282 live: bool,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub struct TableStats {
290 pub records: usize,
291 pub live_bytes: u64,
292 pub dead_bytes: u64,
293 pub log_bytes: u64,
294 pub queue_depth: usize,
298 pub gen: u64,
303 pub seq: u64,
304}
305
306#[derive(Debug, Clone, PartialEq)]
309pub struct HistoryEntry {
310 pub seq: u64,
311 pub ts: i64,
312 pub op: LogOp,
313 pub partition: String,
316 pub offset: u64,
317 pub length: u64,
318 pub document: Option<HaysonRecord>,
322}
323
324#[derive(Debug, Clone, PartialEq)]
327pub struct TableHistory {
328 pub entries: Vec<HistoryEntry>,
331 pub scanned: Vec<PathBuf>,
334}
335
336fn value_as_key(value: &Value) -> String {
337 match value {
338 Value::String(s) => s.clone(),
339 other => other.to_string(),
340 }
341}
342
343fn project_onto_schema(schema: &TableSchema, record: HaysonRecord) -> HaysonRecord {
351 if record.keys().all(|field| schema.fields.contains_key(field)) {
352 return record;
353 }
354 record
355 .into_iter()
356 .filter(|(field, _)| schema.fields.contains_key(field))
357 .collect()
358}
359
360fn default_image_dir(log_path: &Path, table: &str) -> PathBuf {
363 let images = format!("{table}.images");
364 match log_path.parent() {
365 Some(parent) => parent.join(images),
366 None => PathBuf::from(images),
367 }
368}
369
370pub struct Table {
374 schema: TableSchema,
375 registry: TableRegistry,
376 logs: LogSet,
377 queue: WriteQueue,
378 staged: HashMap<String, StagedWrite>,
381 next_op_id: u64,
382 seq: u64,
383 snapshot_path: Option<PathBuf>,
384 schema_hash: String,
385 snapshot_every: u64,
386 since_snapshot: u64,
387 snapshot_current: bool,
388 compaction: CatalogCompaction,
393 sync: CatalogSyncMode,
397 validation: CatalogValidation,
401 deprecated_fields: BTreeMap<String, String>,
406 triggers: Vec<TriggerEntry>,
407 image_dir: PathBuf,
408 link_parsing: LinkParsing,
409 cipher: Option<FieldCipher>,
410 cypher_fields: Vec<String>,
411 declared: Vec<ExpandedIndex>,
416 gen: u64,
417 follow: Option<FollowPolicy>,
420 followed_at: Option<Instant>,
423 cache: RecordCache,
427}
428
429impl Table {
430 pub fn open(schema: TableSchema, path: impl AsRef<Path>) -> io::Result<Self> {
435 Self::open_with_declared_indexes(schema, path, &[])
436 }
437
438 pub fn open_with_declared_indexes(
443 schema: TableSchema,
444 path: impl AsRef<Path>,
445 declared: &[ExpandedIndex],
446 ) -> io::Result<Self> {
447 let image_dir = default_image_dir(path.as_ref(), &schema.table);
448 let cypher_fields = cypher_fields_of(&schema);
449 let mut logs = open_logs(&schema, path.as_ref(), false)?;
450 let registry = TableRegistry::build_from_log_set_with_declared(schema.clone(), &mut logs, declared)?;
451 let seq = registry.max_seq();
452 let queue = WriteQueue::new(schema.table.clone());
453 Ok(Table {
454 schema,
455 registry,
456 logs,
457 queue,
458 staged: HashMap::new(),
459 next_op_id: 0,
460 seq,
461 snapshot_path: None,
462 schema_hash: String::new(),
463 snapshot_every: 0,
464 since_snapshot: 0,
465 snapshot_current: false,
466 compaction: CatalogCompaction::Disabled,
467 sync: CatalogSyncMode::Commit,
468 validation: CatalogValidation::Strict,
469 deprecated_fields: BTreeMap::new(),
470 triggers: Vec::new(),
471 image_dir,
472 link_parsing: LinkParsing::Eager,
473 cipher: None,
474 cypher_fields,
475 declared: declared.to_vec(),
476 gen: 0,
477 follow: None,
478 followed_at: None,
479 cache: RecordCache::new(CachePolicy::Disabled),
480 })
481 }
482
483 pub fn open_with_snapshot(
493 schema: TableSchema,
494 path: impl AsRef<Path>,
495 snapshot_path: impl AsRef<Path>,
496 schema_hash: &str,
497 snapshot_every: u64,
498 ) -> io::Result<(Self, RestoreOutcome)> {
499 Self::open_with_snapshot_and_declared_indexes(
500 schema,
501 path,
502 snapshot_path,
503 schema_hash,
504 snapshot_every,
505 false,
506 &[],
507 )
508 }
509
510 #[allow(clippy::too_many_arguments)]
519 pub fn open_with_snapshot_and_declared_indexes(
520 schema: TableSchema,
521 path: impl AsRef<Path>,
522 snapshot_path: impl AsRef<Path>,
523 schema_hash: &str,
524 snapshot_every: u64,
525 readonly: bool,
526 declared: &[ExpandedIndex],
527 ) -> io::Result<(Self, RestoreOutcome)> {
528 let image_dir = default_image_dir(path.as_ref(), &schema.table);
529 let cypher_fields = cypher_fields_of(&schema);
530 let mut logs = open_logs(&schema, path.as_ref(), readonly)?;
531 let Restored { registry, outcome, gen } = restore_or_rebuild_set_with_declared(
532 snapshot_path.as_ref(),
533 schema.clone(),
534 &mut logs,
535 schema_hash,
536 declared,
537 )?;
538 let seq = registry.max_seq();
539 let queue = WriteQueue::new(schema.table.clone());
540 let table = Table {
541 schema,
542 registry,
543 logs,
544 queue,
545 staged: HashMap::new(),
546 next_op_id: 0,
547 seq,
548 snapshot_path: Some(snapshot_path.as_ref().to_path_buf()),
549 schema_hash: schema_hash.to_string(),
550 snapshot_every,
551 since_snapshot: 0,
552 snapshot_current: outcome == RestoreOutcome::Restored,
553 compaction: CatalogCompaction::Disabled,
554 sync: CatalogSyncMode::Commit,
555 validation: CatalogValidation::Strict,
556 deprecated_fields: BTreeMap::new(),
557 triggers: Vec::new(),
558 image_dir,
559 link_parsing: LinkParsing::Eager,
560 cipher: None,
561 cypher_fields,
562 declared: declared.to_vec(),
563 gen,
564 follow: None,
565 followed_at: None,
566 cache: RecordCache::new(CachePolicy::Disabled),
567 };
568 Ok((table, outcome))
569 }
570
571 pub fn set_triggers(&mut self, triggers: Vec<TriggerEntry>) {
577 self.triggers = triggers;
578 }
579
580 pub fn triggers(&self) -> &[TriggerEntry] {
581 &self.triggers
582 }
583
584 pub fn set_image_dir(&mut self, dir: PathBuf) {
589 self.image_dir = dir;
590 }
591
592 pub fn image_dir(&self) -> &Path {
593 &self.image_dir
594 }
595
596 pub fn set_link_parsing(&mut self, mode: LinkParsing) {
599 self.link_parsing = mode;
600 }
601
602 pub fn link_parsing(&self) -> LinkParsing {
603 self.link_parsing
604 }
605
606 pub fn set_validation(&mut self, mode: CatalogValidation) {
612 self.validation = mode;
613 }
614
615 pub fn validation(&self) -> CatalogValidation {
616 self.validation
617 }
618
619 pub(crate) fn accepted_document<'a>(&self, document: &'a Value) -> Cow<'a, Value> {
624 match self.validation {
625 CatalogValidation::Strict => Cow::Borrowed(document),
626 CatalogValidation::Warn => without_undeclared(&self.schema, document),
627 }
628 }
629
630 pub fn dropped_field_warnings(&self, document: &Value) -> Vec<String> {
638 if self.validation != CatalogValidation::Warn {
639 return Vec::new();
640 }
641 undeclared_fields(&self.schema, document)
642 .into_iter()
643 .map(|field| format!("field \"{field}\": is not declared in the schema, dropped from the write"))
644 .collect()
645 }
646
647 pub fn set_deprecated_fields(&mut self, fields: BTreeMap<String, String>) {
656 self.deprecated_fields = fields;
657 }
658
659 pub fn deprecated_fields(&self) -> &BTreeMap<String, String> {
660 &self.deprecated_fields
661 }
662
663 pub fn deprecated_field_warnings(&self, document: &Value) -> Vec<String> {
673 if self.deprecated_fields.is_empty() {
674 return Vec::new();
675 }
676 let Some(source) = document.as_object() else {
677 return Vec::new();
678 };
679 source
680 .keys()
681 .filter_map(|field| self.deprecated_fields.get_key_value(field.as_str()))
682 .map(|(field, note)| {
683 if note.is_empty() {
684 format!("field \"{field}\": is deprecated")
685 } else {
686 format!("field \"{field}\": is deprecated — {note}")
687 }
688 })
689 .collect()
690 }
691
692 pub fn set_compaction(&mut self, compaction: CatalogCompaction) {
695 self.compaction = compaction;
696 }
697
698 pub fn compaction(&self) -> CatalogCompaction {
699 self.compaction
700 }
701
702 pub fn set_cache(&mut self, cache: CachePolicy) {
706 self.cache = RecordCache::new(cache);
707 }
708
709 pub fn cache_policy(&self) -> CachePolicy {
710 self.cache.policy()
711 }
712
713 pub fn set_sync(&mut self, sync: CatalogSyncMode) {
718 self.sync = sync;
719 self.logs.set_fsync(sync == CatalogSyncMode::Commit);
720 }
721
722 pub fn sync_mode(&self) -> CatalogSyncMode {
723 self.sync
724 }
725
726 pub fn set_cipher(&mut self, cipher: Option<FieldCipher>) {
731 self.cipher = cipher;
732 self.cache.clear();
734 }
735
736 fn write_image_fields(&self, source: &Value) -> io::Result<()> {
742 let Some(object) = source.as_object() else {
743 return Ok(());
744 };
745 for (name, spec) in &self.schema.fields {
746 if spec.field_type != FieldType::Image {
747 continue;
748 }
749 let Some(content) = object
750 .get(name)
751 .and_then(|value| value.get("content"))
752 .and_then(Value::as_str)
753 else {
754 continue;
755 };
756 let decoded = crate::schema::decode_image_content(content)
757 .map_err(|reason| io::Error::new(io::ErrorKind::InvalidData, reason))?;
758 self.write_image_bytes(&decoded)?;
759 }
760 Ok(())
761 }
762
763 fn broken_markdown_links(&self, record: &HaysonRecord) -> Vec<BrokenLink> {
767 let mut broken = Vec::new();
768 for (name, spec) in &self.schema.fields {
769 if spec.field_type != FieldType::Markdown {
770 continue;
771 }
772 let Some(content) = record
773 .get(name)
774 .and_then(|value| value.get("content"))
775 .and_then(Value::as_str)
776 else {
777 continue;
778 };
779 let field_broken = broken_links(content, |target| {
780 self.registry
781 .virtual_path_fields()
782 .any(|field| self.registry.virtual_path_holder(field, target).is_some())
783 });
784 broken.extend(field_broken.into_iter().map(|target| BrokenLink {
785 field: name.clone(),
786 path: target,
787 }));
788 }
789 broken
790 }
791
792 fn eager_link_warnings(&self, record: &HaysonRecord) -> Vec<BrokenLink> {
795 if self.link_parsing != LinkParsing::Eager {
796 return Vec::new();
797 }
798 self.broken_markdown_links(record)
799 }
800
801 pub fn eager_link_warnings_of(&self, key: &str) -> Result<Vec<BrokenLink>, TableError> {
809 if self.link_parsing != LinkParsing::Eager {
810 return Ok(Vec::new());
811 }
812 self.broken_links_of(key)
813 }
814
815 pub fn broken_links_of(&self, key: &str) -> Result<Vec<BrokenLink>, TableError> {
818 let record = self.get(key)?.ok_or_else(|| {
819 TableError::NotFound(HaysonNotFoundError {
820 table: self.schema.table.clone(),
821 key: key.to_string(),
822 })
823 })?;
824 Ok(self.broken_markdown_links(&record))
825 }
826
827 fn write_image_bytes(&self, bytes: &[u8]) -> io::Result<()> {
828 let file_path = self.image_dir.join(physical_path_of(bytes));
829 if file_path.exists() {
830 return Ok(());
831 }
832 fs::create_dir_all(&self.image_dir)?;
833 fs::write(file_path, bytes)
834 }
835
836 pub fn snapshot(&mut self) -> io::Result<()> {
840 self.flush()?;
841 self.write_snapshot_now()
842 }
843
844 fn write_snapshot_now(&mut self) -> io::Result<()> {
847 let Some(path) = self.snapshot_path.clone() else {
848 return Ok(());
849 };
850 write_snapshot_set(
851 &path,
852 &self.registry,
853 &self.schema.table,
854 &self.schema_hash,
855 self.gen,
856 &self.logs,
857 )?;
858 self.since_snapshot = 0;
859 self.snapshot_current = true;
860 Ok(())
861 }
862
863 pub fn needs_snapshot(&self) -> bool {
866 self.snapshot_path.is_some() && !self.snapshot_current
867 }
868
869 pub fn rotate_cypher_keys(&mut self) -> Result<RotationResult, TableError> {
891 let mut result = RotationResult::default();
892 if self.cypher_fields.is_empty() {
893 return Ok(result);
894 }
895 if self.logs.is_readonly() {
896 return Err(TableError::Io(io::Error::other(format!(
897 "table \"{}\" is readonly — rotating cypher keys is not available",
898 self.schema.table
899 ))));
900 }
901 self.flush()?;
903 let Some(cipher) = self.cipher.clone() else {
904 return Err(TableError::Cipher(Box::new(HaysonCipherError::new(
905 self.schema.table.clone(),
906 "",
907 "cannot rotate cypher keys without a key",
908 ))));
909 };
910
911 let keys: Vec<String> = self.keys().cloned().collect();
912 for key in keys {
913 let Some(sealed) = self.sealed_record(&key)? else {
914 continue;
915 };
916 match self.rotation_verdict(&sealed, &cipher) {
917 RotationVerdict::Current => result.already_current += 1,
918 RotationVerdict::Missing(key_id) => match cipher.on_missing_key() {
919 MissingKeyPolicy::Throw => {
920 return Err(TableError::Cipher(Box::new(
921 HaysonCipherError::new(
922 self.schema.table.clone(),
923 "",
924 format!("record \"{key}\" was sealed with key \"{key_id}\", which was not given"),
925 )
926 .with_key_id(&key_id),
927 )))
928 }
929 MissingKeyPolicy::Omit | MissingKeyPolicy::Raw => result.skipped += 1,
930 },
931 RotationVerdict::Rotate => {
932 let opened = open_record(&self.schema, &key, &sealed, Some(&cipher), &self.cypher_fields)
933 .map_err(|error| TableError::Cipher(Box::new(error)))?;
934 let document = Value::Object(opened.into_iter().collect());
935 self.put_from_buffer(&document, false)?;
936 result.rotated += 1;
937 }
938 }
939 }
940 Ok(result)
941 }
942
943 fn sealed_record(&self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
946 let mut source = LogSetRecordSource::new(&self.registry, &self.logs);
947 source.load(key).map_err(TableError::from)
948 }
949
950 fn rotation_verdict(&self, sealed: &HaysonRecord, cipher: &FieldCipher) -> RotationVerdict {
951 let mut rotate = false;
952 for field in &self.cypher_fields {
953 let Some(value) = sealed.get(field) else { continue };
954 if value.is_null() {
955 continue;
956 }
957 match envelope_key_id(value) {
958 Some(key_id) if !cipher.knows(key_id) => return RotationVerdict::Missing(key_id.to_string()),
959 Some(key_id) => rotate |= key_id != cipher.write_key_id(),
960 None => rotate = true,
963 }
964 }
965 if rotate {
966 RotationVerdict::Rotate
967 } else {
968 RotationVerdict::Current
969 }
970 }
971
972 pub fn compact(&mut self) -> io::Result<crate::compaction::CompactionResult> {
982 self.flush()?;
984 self.cache.clear();
985 let mut total = crate::compaction::CompactionResult {
986 slots: HashMap::new(),
987 records: 0,
988 bytes_before: 0,
989 bytes_after: 0,
990 reclaimed_bytes: 0,
991 };
992 let mut swapped = false;
993 for part in self.logs.parts() {
994 if !swapped {
995 if let Some(path) = self.snapshot_path.as_deref() {
996 remove_snapshot(path)?;
997 }
998 self.snapshot_current = false;
999 swapped = true;
1000 }
1001 let Some(log) = self.logs.log_of_mut(&part) else {
1002 continue;
1003 };
1004 let result = crate::compaction::compact_log(log)?;
1005 self.registry.remap(&result.slots, &part);
1006 total.records += result.records;
1007 total.bytes_before += result.bytes_before;
1008 total.bytes_after += result.bytes_after;
1009 total.reclaimed_bytes += result.reclaimed_bytes;
1010 total.slots.extend(result.slots);
1011 }
1012 if swapped {
1013 self.gen += 1;
1015 self.snapshot()?;
1016 }
1017 Ok(total)
1018 }
1019
1020 pub fn refresh(&mut self) -> io::Result<RefreshOutcome> {
1037 let appeared = self.logs.discover()?;
1038 let mut entries = 0;
1039
1040 for part in self.logs.parts() {
1041 if appeared.contains(&part) {
1042 let Some(log) = self.logs.log_of(&part) else { continue };
1044 let scan = log.scan(0)?;
1045 entries += scan.entries.len();
1046 self.invalidate_scanned(&scan);
1047 self.registry.apply_scan_in(scan, &part);
1048 continue;
1049 }
1050
1051 let Some(log) = self.logs.log_of_mut(&part) else {
1052 continue;
1053 };
1054 let Some(probe) = log.probe() else { continue };
1055 if rewritten(log, &probe) {
1056 self.reload()?;
1057 return Ok(RefreshOutcome::Reloaded);
1058 }
1059 if probe.size == log.size() {
1060 continue;
1061 }
1062 let from = log.size();
1063 log.adopt_size(probe.size);
1064 let scan = log.scan(from)?;
1065 log.adopt_size(scan.end_offset);
1067 entries += scan.entries.len();
1068 self.invalidate_scanned(&scan);
1069 self.registry.apply_scan_in(scan, &part);
1070 }
1071
1072 self.seq = self.seq.max(self.registry.max_seq());
1073 if entries == 0 {
1074 return Ok(RefreshOutcome::Unchanged);
1075 }
1076 self.snapshot_current = false;
1077 Ok(RefreshOutcome::CaughtUp { entries })
1078 }
1079
1080 fn invalidate_scanned(&self, scan: &ScanResult) {
1084 for found in &scan.entries {
1085 self.cache.invalidate(&found.entry.meta.key);
1086 }
1087 }
1088
1089 pub fn set_follow(&mut self, follow: Option<FollowPolicy>) {
1093 self.follow = follow;
1094 }
1095
1096 pub fn follow(&self) -> Option<FollowPolicy> {
1097 self.follow
1098 }
1099
1100 pub fn follow_if_due(&mut self) -> io::Result<Option<RefreshOutcome>> {
1111 if !self.follow_is_due() {
1112 return Ok(None);
1113 }
1114 self.followed_at = Some(Instant::now());
1115 Ok(Some(self.refresh()?))
1116 }
1117
1118 pub fn follow_is_due(&self) -> bool {
1126 let Some(policy) = self.follow else { return false };
1127 policy.interval.is_zero() || self.followed_at.is_none_or(|at| at.elapsed() >= policy.interval)
1128 }
1129
1130 pub fn has_staged_writes(&self) -> bool {
1136 self.queue.depth() > 0 || !self.staged.is_empty()
1137 }
1138
1139 pub fn get_following(&mut self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
1147 self.follow_if_due()?;
1148 self.get(key)
1149 }
1150
1151 pub fn get_many_following(&mut self, keys: &[String]) -> Result<Vec<Option<HaysonRecord>>, TableError> {
1154 self.follow_if_due()?;
1155 keys.iter().map(|key| self.get(key)).collect()
1156 }
1157
1158 pub fn query_following(&mut self, spec: &QuerySpec) -> Result<QueryResult, TableError> {
1161 self.flush()?;
1162 self.follow_if_due()?;
1163 self.query(spec)
1164 }
1165
1166 pub fn aggregate_following(
1168 &mut self,
1169 spec: &QuerySpec,
1170 aggregate: &AggregateSpec,
1171 ) -> Result<AggregateResult, TableError> {
1172 self.flush()?;
1173 self.follow_if_due()?;
1174 self.aggregate(spec, aggregate)
1175 }
1176
1177 pub fn aggregate_grouped_following(
1179 &mut self,
1180 spec: &QuerySpec,
1181 aggregate: &AggregateSpec,
1182 ) -> Result<GroupedAggregateResult, TableError> {
1183 self.flush()?;
1184 self.follow_if_due()?;
1185 self.aggregate_grouped(spec, aggregate)
1186 }
1187
1188 fn reload(&mut self) -> io::Result<()> {
1192 self.logs.discover()?;
1193 let mut registry = TableRegistry::with_declared_indexes(self.schema.clone(), &self.declared);
1194 for part in self.logs.parts() {
1195 let Some(log) = self.logs.log_of_mut(&part) else {
1196 continue;
1197 };
1198 log.reopen()?;
1199 let scan = log.scan(0)?;
1200 log.adopt_size(scan.end_offset);
1202 registry.apply_scan_in(scan, &part);
1203 }
1204 self.registry = registry;
1205 self.cache.clear();
1206 self.seq = self.seq.max(self.registry.max_seq());
1207 self.gen += 1;
1208 self.snapshot_current = false;
1209 Ok(())
1210 }
1211
1212 pub fn keys(&self) -> impl Iterator<Item = &String> {
1214 self.registry.keys()
1215 }
1216
1217 pub fn has(&self, key: &str) -> bool {
1220 match self.staged.get(key) {
1221 Some(staged) => staged.record.is_some(),
1222 None => self.registry.slot_of(key).is_some(),
1223 }
1224 }
1225
1226 pub fn len(&self) -> usize {
1233 self.registry.records()
1234 }
1235
1236 pub fn is_empty(&self) -> bool {
1237 self.len() == 0
1238 }
1239
1240 pub fn pending(&self, key: &str) -> HashSet<String> {
1244 self.queue.pending(key)
1245 }
1246
1247 pub fn stats(&self) -> TableStats {
1250 let stats = self.registry.stats();
1251 TableStats {
1252 records: stats.records,
1253 live_bytes: stats.live_bytes,
1254 dead_bytes: stats.dead_bytes,
1255 log_bytes: self.logs.total_size(),
1256 queue_depth: self.queue.depth(),
1257 gen: self.gen,
1258 seq: self.seq,
1259 }
1260 }
1261
1262 fn note_writes(&mut self, count: usize) -> io::Result<()> {
1263 if count == 0 {
1264 return Ok(());
1265 }
1266 self.since_snapshot += count as u64;
1267 self.snapshot_current = false;
1268 self.compact_if_due()?;
1269 if self.snapshot_every > 0 && self.since_snapshot >= self.snapshot_every {
1270 self.write_snapshot_now()?;
1271 }
1272 Ok(())
1273 }
1274
1275 fn compact_if_due(&mut self) -> io::Result<()> {
1288 let CatalogCompaction::Enabled {
1289 dead_bytes_ratio,
1290 min_dead_bytes,
1291 } = self.compaction
1292 else {
1293 return Ok(());
1294 };
1295 let mut compacted = false;
1296 for part in self.logs.parts() {
1297 let bytes = self.registry.stats_of(&part);
1298 let total = bytes.live_bytes + bytes.dead_bytes;
1299 if total == 0 || bytes.dead_bytes < min_dead_bytes {
1300 continue;
1301 }
1302 if (bytes.dead_bytes as f64) / (total as f64) < dead_bytes_ratio {
1303 continue;
1304 }
1305 let Some(log) = self.logs.log_of_mut(&part) else {
1306 continue;
1307 };
1308 if !compacted {
1309 if let Some(path) = self.snapshot_path.as_deref() {
1311 remove_snapshot(path)?;
1312 }
1313 self.snapshot_current = false;
1314 }
1315 let result = crate::compaction::compact_log(log)?;
1316 self.registry.remap(&result.slots, &part);
1317 compacted = true;
1318 }
1319 if !compacted {
1320 return Ok(());
1321 }
1322 self.cache.clear();
1323 self.gen += 1;
1325 self.write_snapshot_now()
1326 }
1327
1328 pub fn name(&self) -> &str {
1329 &self.schema.table
1330 }
1331
1332 pub fn schema(&self) -> &TableSchema {
1333 &self.schema
1334 }
1335
1336 pub fn declared_indexes(&self) -> &[ExpandedIndex] {
1340 &self.declared
1341 }
1342
1343 pub fn get(&self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
1347 match self.staged.get(key) {
1348 Some(staged) => Ok(staged.record.clone()),
1349 None => self.committed(key),
1350 }
1351 }
1352
1353 fn committed(&self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
1356 if let Some(cached) = self.cache.get(key) {
1357 return Ok(Some(cached));
1358 }
1359 let found = self.with_record_source(|source| source.load(key))?;
1360 if let Some(record) = &found {
1361 self.cache.insert(key, record);
1362 }
1363 Ok(found)
1364 }
1365
1366 fn with_record_source<T>(
1376 &self,
1377 f: impl FnOnce(&mut dyn RecordSource) -> Result<T, RecordSourceError>,
1378 ) -> Result<T, TableError> {
1379 let open = |key: &str, record: HaysonRecord| -> Result<HaysonRecord, RecordSourceError> {
1380 let opened = if self.cypher_fields.is_empty() {
1381 record
1382 } else {
1383 open_record(&self.schema, key, &record, self.cipher.as_ref(), &self.cypher_fields)?
1384 };
1385 Ok(project_onto_schema(&self.schema, opened))
1386 };
1387 let mut source = LogSetRecordSource::with_open(&self.registry, &self.logs, &open);
1388 f(&mut source).map_err(TableError::from)
1389 }
1390
1391 pub fn history(&self, key: &str) -> io::Result<TableHistory> {
1402 let mut entries = Vec::new();
1403 let mut scanned = Vec::new();
1404 for part in self.logs.parts() {
1405 let Some(log) = self.logs.log_of(&part) else { continue };
1406 scanned.push(log.path().to_path_buf());
1407 for found in log.scan(0)?.entries {
1408 if found.entry.meta.key != key {
1409 continue;
1410 }
1411 entries.push(HistoryEntry {
1412 seq: found.entry.meta.seq,
1413 ts: found.entry.meta.ts,
1414 op: found.entry.meta.op,
1415 partition: part.clone(),
1416 offset: found.slot.offset,
1417 length: found.slot.length,
1418 document: found.entry.data,
1419 });
1420 }
1421 }
1422 entries.sort_by_key(|entry| entry.seq);
1423 Ok(TableHistory { entries, scanned })
1424 }
1425
1426 pub fn put(&mut self, document: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1427 self.put_impl(document, force, true).map(PutOutcome::into_record)
1428 }
1429
1430 pub fn put_sealed(&mut self, document: &Value, sealed: &[String], force: bool) -> Result<HaysonRecord, TableError> {
1447 if sealed.is_empty() {
1448 return self.put(document, force);
1449 }
1450 let accepted = self.accepted_document(document);
1451 let document = accepted.as_ref();
1452 let candidate = validate_record(&self.schema, document, ValidateOptions::default())?;
1453 let key = primary_key_of(&self.schema, &candidate)?;
1454 for field in sealed {
1455 if !self.cypher_fields.iter().any(|name| name == field) {
1456 return Err(TableError::Cipher(Box::new(HaysonCipherError::new(
1457 self.schema.table.clone(),
1458 field.clone(),
1459 "cannot be carried over as already sealed — it is not declared \"cypher\"",
1460 ))));
1461 }
1462 }
1463 assert_carried_seals(&self.schema, &key, &candidate, self.cipher.as_ref(), sealed)?;
1465
1466 self.assert_partition_stable_against_staged(document)?;
1467 let key = self
1468 .queue
1469 .stage_put_carrying(&self.schema, &self.registry, document, sealed.to_vec(), force)?;
1470 let Some(record) = self.queue.last_put_record().cloned() else {
1471 unreachable!("stage_put_carrying queues exactly one put entry")
1472 };
1473 self.write_image_fields(document)?;
1474 self.stage(key, Some(record.clone()), true)?;
1475 self.flush()?;
1476 Ok(record)
1477 }
1478
1479 pub fn put_with_warnings(&mut self, document: &Value, force: bool) -> Result<PutOutcome, TableError> {
1483 self.put_impl(document, force, true)
1484 }
1485
1486 pub(crate) fn put_from_buffer(&mut self, document: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1490 self.put_impl(document, force, false).map(PutOutcome::into_record)
1491 }
1492
1493 fn put_impl(&mut self, document: &Value, force: bool, live: bool) -> Result<PutOutcome, TableError> {
1494 let record = self.stage_put_impl(document, force, live)?;
1495 self.flush()?;
1496 let warnings = self.eager_link_warnings(&record);
1497 Ok(PutOutcome { record, warnings })
1498 }
1499
1500 pub fn stage_put(&mut self, document: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1509 self.stage_put_impl(document, force, true)
1510 }
1511
1512 fn stage_put_impl(&mut self, document: &Value, force: bool, live: bool) -> Result<HaysonRecord, TableError> {
1513 let accepted = self.accepted_document(document);
1514 let document = accepted.as_ref();
1515 self.assert_partition_stable_against_staged(document)?;
1516 let key = self.queue.stage_put(&self.schema, &self.registry, document, force)?;
1517 let Some(record) = self.queue.last_put_record().cloned() else {
1518 unreachable!("stage_put queues exactly one put entry")
1519 };
1520 self.write_image_fields(document)?;
1521 self.stage(key, Some(record.clone()), live)?;
1522 Ok(record)
1523 }
1524
1525 fn stage(&mut self, key: String, record: Option<HaysonRecord>, live: bool) -> io::Result<()> {
1528 let (id, old) = match self.staged.get(&key) {
1529 Some(held) => (held.id, held.old.clone()),
1530 None => {
1531 self.next_op_id += 1;
1532 (self.next_op_id, self.committed(&key)?)
1533 }
1534 };
1535 self.staged.insert(key, StagedWrite { id, record, old, live });
1536 Ok(())
1537 }
1538
1539 fn assert_partition_stable_against_staged(&self, document: &Value) -> Result<(), TableError> {
1546 if self.staged.is_empty() || !is_partitioned(&self.schema) {
1547 return Ok(());
1548 }
1549 let record = validate_record(&self.schema, document, ValidateOptions::default())?;
1550 let key = primary_key_of(&self.schema, &record)?;
1551 let Some(StagedWrite { record: Some(held), .. }) = self.staged.get(&key) else {
1552 return Ok(());
1553 };
1554 let held_part = partition_of(&self.schema, held)?;
1555 let part = partition_of(&self.schema, &record)?;
1556 if held_part == part {
1557 return Ok(());
1558 }
1559 Err(TableError::Partition(HaysonPartitionError::new(
1560 self.schema.table.clone(),
1561 self.schema.partition_by.clone().unwrap_or_default(),
1562 format!(
1563 "key \"{key}\" already lives in partition \"{held_part}\" — delete it before writing it into \"{part}\""
1564 ),
1565 )))
1566 }
1567
1568 pub fn flush(&mut self) -> Result<(), TableError> {
1575 if self.queue.depth() == 0 && self.staged.is_empty() {
1576 return Ok(());
1577 }
1578 self.commit()?;
1579
1580 let mut window: Vec<StagedWrite> = self.staged.drain().map(|(_, staged)| staged).collect();
1581 window.sort_by_key(|staged| staged.id);
1582 for staged in window {
1583 if !staged.live || (staged.record.is_none() && staged.old.is_none()) {
1584 continue;
1585 }
1586 let event = match (&staged.old, &staged.record) {
1587 (_, None) => TriggerEvent::Delete,
1588 (Some(_), Some(_)) => TriggerEvent::Update,
1589 (None, Some(_)) => TriggerEvent::Insert,
1590 };
1591 self.fire_same_table_triggers(event, staged.old.as_ref(), staged.record.as_ref())?;
1592 }
1593 Ok(())
1594 }
1595
1596 pub fn patch(&mut self, key: &str, changes: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1600 self.patch_impl(key, changes, force, true).map(PutOutcome::into_record)
1601 }
1602
1603 pub fn patch_with_warnings(&mut self, key: &str, changes: &Value, force: bool) -> Result<PutOutcome, TableError> {
1606 self.patch_impl(key, changes, force, true)
1607 }
1608
1609 pub(crate) fn patch_from_buffer(
1611 &mut self,
1612 key: &str,
1613 changes: &Value,
1614 force: bool,
1615 ) -> Result<HaysonRecord, TableError> {
1616 self.patch_impl(key, changes, force, false).map(PutOutcome::into_record)
1617 }
1618
1619 fn patch_impl(&mut self, key: &str, changes: &Value, force: bool, live: bool) -> Result<PutOutcome, TableError> {
1620 let record = self.stage_patch_impl(key, changes, force, live)?;
1621 self.flush()?;
1622 let warnings = self.eager_link_warnings(&record);
1623 Ok(PutOutcome { record, warnings })
1624 }
1625
1626 pub fn stage_patch(&mut self, key: &str, changes: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1634 self.stage_patch_impl(key, changes, force, true)
1635 }
1636
1637 fn stage_patch_impl(
1638 &mut self,
1639 key: &str,
1640 changes: &Value,
1641 force: bool,
1642 live: bool,
1643 ) -> Result<HaysonRecord, TableError> {
1644 let accepted = self.accepted_document(changes);
1645 let changes = accepted.as_ref();
1646 let (_, merged) = self.merged_patch(key, changes)?;
1647 let claim: Vec<String> = match changes.as_object() {
1648 Some(object) => object.keys().cloned().collect(),
1649 None => Vec::new(),
1650 };
1651 let document = Value::Object(merged.into_iter().collect());
1652 self.assert_partition_stable_against_staged(&document)?;
1653 let key = self
1654 .queue
1655 .stage_put_claiming(&self.schema, &self.registry, &document, &claim, force)?;
1656 let Some(record) = self.queue.last_put_record().cloned() else {
1657 unreachable!("stage_put_claiming queues exactly one put entry")
1658 };
1659 self.write_image_fields(changes)?;
1660 self.stage(key, Some(record.clone()), live)?;
1661 Ok(record)
1662 }
1663
1664 fn merged_patch(&self, key: &str, changes: &Value) -> Result<(HaysonRecord, HaysonRecord), TableError> {
1665 let base = self.get(key)?;
1666 self.merged_patch_over(key, base, changes)
1667 }
1668
1669 fn shadowed_state(&self, shadow: &UnitShadow, key: &str) -> Result<Option<HaysonRecord>, TableError> {
1673 match shadow.get(key) {
1674 Some(held) => Ok(held.clone()),
1675 None => Ok(self.get(key)?),
1676 }
1677 }
1678
1679 fn merged_patch_over(
1683 &self,
1684 key: &str,
1685 base: Option<HaysonRecord>,
1686 changes: &Value,
1687 ) -> Result<(HaysonRecord, HaysonRecord), TableError> {
1688 let current = base.ok_or_else(|| {
1689 TableError::NotFound(HaysonNotFoundError {
1690 table: self.schema.table.clone(),
1691 key: key.to_string(),
1692 })
1693 })?;
1694 let partial = validate_record(
1695 &self.schema,
1696 changes,
1697 ValidateOptions {
1698 partial: true,
1699 now: None,
1700 },
1701 )?;
1702 if let Some(new_key) = partial.get(&self.schema.primary_key) {
1703 if value_as_key(new_key) != key {
1704 return Err(TableError::Schema(HaysonSchemaError(format!(
1705 "the primary key of \"{key}\" cannot be changed by patch"
1706 ))));
1707 }
1708 }
1709
1710 let mut merged = current.clone();
1711 for (field, value) in &partial {
1712 merged.insert(field.clone(), value.clone());
1713 }
1714 Ok((current, merged))
1715 }
1716
1717 pub fn delete(&mut self, key: &str, force: bool) -> Result<bool, TableError> {
1718 self.delete_impl(key, force, true)
1719 }
1720
1721 pub(crate) fn delete_from_buffer(&mut self, key: &str, force: bool) -> Result<bool, TableError> {
1723 self.delete_impl(key, force, false)
1724 }
1725
1726 fn delete_impl(&mut self, key: &str, force: bool, live: bool) -> Result<bool, TableError> {
1727 let existed = self.stage_delete_impl(key, force, live)?;
1728 self.flush()?;
1729 Ok(existed)
1730 }
1731
1732 pub fn stage_delete(&mut self, key: &str, force: bool) -> Result<bool, TableError> {
1736 self.stage_delete_impl(key, force, true)
1737 }
1738
1739 fn stage_delete_impl(&mut self, key: &str, force: bool, live: bool) -> Result<bool, TableError> {
1740 let old = self.get(key)?;
1741 let part = self.partition_holding(key)?;
1742 if part.is_none() && is_partitioned(&self.schema) {
1744 return Ok(false);
1745 }
1746 self.queue
1747 .stage_delete(key, force, part.as_deref().unwrap_or(UNPARTITIONED))?;
1748 self.stage(key.to_string(), None, live)?;
1749 Ok(old.is_some())
1750 }
1751
1752 fn partition_holding(&self, key: &str) -> Result<Option<String>, TableError> {
1755 match self.staged.get(key) {
1756 Some(StagedWrite {
1757 record: Some(record), ..
1758 }) => Ok(Some(partition_of(&self.schema, record)?)),
1759 Some(_) => Ok(None),
1760 None => Ok(self.registry.part_of(key).map(str::to_string)),
1761 }
1762 }
1763
1764 fn fire_same_table_triggers(
1769 &mut self,
1770 event: TriggerEvent,
1771 old: Option<&HaysonRecord>,
1772 new: Option<&HaysonRecord>,
1773 ) -> Result<(), TableError> {
1774 let triggers = self.triggers.clone();
1775 let firing = TriggerFiring { event, old, new };
1776 for trigger in matching(&triggers, &self.schema.table, event) {
1777 if trigger.effect.table != self.schema.table {
1778 continue;
1779 }
1780 let document = effect_document(&trigger.effect, &firing);
1781 self.write_effect(&document)?;
1782 }
1783 Ok(())
1784 }
1785
1786 fn write_effect(&mut self, document: &Value) -> Result<(), TableError> {
1787 self.queue.stage_put(&self.schema, &self.registry, document, true)?;
1788 self.commit()?;
1789 Ok(())
1790 }
1791
1792 fn assert_searchable(&self, spec: &QuerySpec) -> Result<(), TableError> {
1795 if spec.search.is_none() || self.registry.has_fulltext() {
1796 return Ok(());
1797 }
1798 Err(TableError::Schema(HaysonSchemaError(format!(
1799 "table \"{}\" has no field marked \"fulltext\": true — search is not available",
1800 self.schema.table
1801 ))))
1802 }
1803
1804 pub fn query(&self, spec: &QuerySpec) -> Result<QueryResult, TableError> {
1810 self.assert_searchable(spec)?;
1811 self.with_record_source(|source| execute_query(&self.registry, source, spec))
1812 }
1813
1814 pub fn query_with_overlay(
1819 &self,
1820 spec: &QuerySpec,
1821 overlay: &dyn QueryOverlay,
1822 delta: &CorpusDelta,
1823 ) -> Result<QueryResult, TableError> {
1824 self.assert_searchable(spec)?;
1825 let scores = spec
1826 .search
1827 .as_ref()
1828 .filter(|_| self.registry.has_fulltext())
1829 .map(|search| self.registry.fulltext().search(&search.text, search.mode, Some(delta)));
1830 let access = plan_access_overlaid(&self.registry, spec, scores, overlay);
1831 self.with_record_source(|source| execute_access(&self.registry, source, spec, access, Some(overlay)))
1832 }
1833
1834 pub fn indexed_fields(&self) -> Vec<String> {
1837 self.registry.indexed_fields()
1838 }
1839
1840 pub fn has_fulltext(&self) -> bool {
1841 self.registry.has_fulltext()
1842 }
1843
1844 pub fn tokens_of(&self, record: &HaysonRecord) -> Vec<String> {
1847 self.registry.tokens_of(record)
1848 }
1849
1850 pub fn aggregate(&self, spec: &QuerySpec, aggregate: &AggregateSpec) -> Result<AggregateResult, TableError> {
1851 self.assert_searchable(spec)?;
1852 self.with_record_source(|source| execute_aggregate(&self.registry, source, spec, aggregate))
1853 }
1854
1855 pub fn aggregate_grouped(
1857 &self,
1858 spec: &QuerySpec,
1859 aggregate: &AggregateSpec,
1860 ) -> Result<GroupedAggregateResult, TableError> {
1861 self.assert_searchable(spec)?;
1862 self.with_record_source(|source| execute_grouped_aggregate(&self.registry, source, spec, aggregate))
1863 }
1864
1865 fn commit(&mut self) -> Result<Vec<WriteOutcome>, TableError> {
1866 for key in self.queue.queued_keys() {
1868 self.cache.invalidate(key);
1869 }
1870 let schema = &self.schema;
1871 let cipher = self.cipher.as_ref();
1872 let cypher_fields = &self.cypher_fields;
1873 let seal = |key: &str, record: &HaysonRecord, carried: &[String]| -> Result<HaysonRecord, HaysonCipherError> {
1874 seal_record(schema, key, record, cipher, cypher_fields, carried)
1875 };
1876 let outcomes = self
1877 .queue
1878 .commit_sealed(&mut self.logs, &mut self.registry, &mut self.seq, true, &seal)?;
1879 self.note_writes(outcomes.len())?;
1880 Ok(outcomes)
1881 }
1882
1883 pub fn current_seq(&self) -> u64 {
1887 self.seq
1888 }
1889
1890 pub fn prepare_txn_ops(&mut self, ops: &[TxnOp]) -> Result<Vec<PreparedTxnEntry>, TableError> {
1899 self.flush()?;
1901 let mut prepared = Vec::with_capacity(ops.len());
1902 let now = chrono::Utc::now().timestamp_millis();
1903 let mut staged_parts: HashMap<String, String> = HashMap::new();
1905 let mut shadow = UnitShadow::new();
1907 for op in ops {
1908 let (key, old, record) = match op {
1909 TxnOp::Delete(key) => (key.clone(), self.shadowed_state(&shadow, key)?, None),
1910 TxnOp::Put(document) => {
1911 let accepted = self.accepted_document(document);
1912 let record = validate_record(&self.schema, accepted.as_ref(), ValidateOptions::default())?;
1913 let key = primary_key_of(&self.schema, &record)?;
1914 assert_unique_in_unit(&self.schema, &self.registry, &key, &record, &shadow)?;
1915 let old = self.shadowed_state(&shadow, &key)?;
1916 (key, old, Some(record))
1917 }
1918 TxnOp::Patch(key, changes) => {
1919 let accepted = self.accepted_document(changes);
1920 let base = self.shadowed_state(&shadow, key)?;
1921 let (old, merged) = self.merged_patch_over(key, base, accepted.as_ref())?;
1922 assert_unique_in_unit(&self.schema, &self.registry, key, &merged, &shadow)?;
1923 (key.clone(), Some(old), Some(merged))
1924 }
1925 };
1926
1927 let part = match &record {
1928 Some(record) => {
1929 let part = partition_of(&self.schema, record)?;
1930 assert_partition_stable(&self.schema, &self.registry, &key, &part)?;
1931 if let Some(held) = staged_parts.get(&key) {
1932 if held != &part {
1933 return Err(TableError::Partition(HaysonPartitionError::new(
1934 self.schema.table.clone(),
1935 self.schema.partition_by.clone().unwrap_or_default(),
1936 format!("key \"{key}\" already lives in partition \"{held}\" — delete it before writing it into \"{part}\""),
1937 )));
1938 }
1939 }
1940 staged_parts.insert(key.clone(), part.clone());
1941 part
1942 }
1943 None => {
1944 let held = staged_parts
1945 .get(&key)
1946 .cloned()
1947 .or_else(|| self.registry.part_of(&key).map(str::to_string));
1948 match held {
1949 Some(part) => part,
1950 None if is_partitioned(&self.schema) => continue,
1952 None => UNPARTITIONED.to_string(),
1953 }
1954 }
1955 };
1956
1957 shadow.insert(key.clone(), record.clone());
1958 self.seq += 1;
1959 let meta = LogMeta {
1960 key: key.clone(),
1961 op: if record.is_some() { LogOp::Put } else { LogOp::Del },
1962 seq: self.seq,
1963 ts: now,
1964 };
1965 let sealed = record
1967 .as_ref()
1968 .map(|rec| seal_record(&self.schema, &key, rec, self.cipher.as_ref(), &self.cypher_fields, &[]))
1969 .transpose()?;
1970 let line = serialize_entry(&meta, sealed.as_ref());
1971 prepared.push(PreparedTxnEntry {
1972 key,
1973 seq: self.seq,
1974 line,
1975 old,
1976 record,
1977 part,
1978 });
1979 }
1980 Ok(prepared)
1981 }
1982
1983 pub fn apply_txn_entries(&mut self, entries: Vec<PreparedTxnEntry>) -> io::Result<()> {
1987 if entries.is_empty() {
1988 return Ok(());
1989 }
1990 let applied = entries.len();
1991 let mut slots: Vec<Option<Slot>> = vec![None; entries.len()];
1992 for part in ordered_parts(&entries) {
1993 let positions: Vec<usize> = entries
1994 .iter()
1995 .enumerate()
1996 .filter(|(_, entry)| entry.part == part)
1997 .map(|(at, _)| at)
1998 .collect();
1999 let lines: Vec<String> = positions.iter().map(|at| entries[*at].line.clone()).collect();
2000 for (at, slot) in positions.into_iter().zip(self.logs.append(&part, &lines)?) {
2001 slots[at] = Some(slot);
2002 }
2003 }
2004 self.logs.sync()?;
2005 for (entry, slot) in entries.into_iter().zip(slots) {
2006 let Some(slot) = slot else { continue };
2007 self.cache.invalidate(&entry.key);
2008 match entry.record {
2009 Some(record) => self
2010 .registry
2011 .apply_put_in(&entry.key, &record, slot, entry.seq, &entry.part),
2012 None => self
2013 .registry
2014 .apply_delete_in(&entry.key, slot.length, entry.seq, &entry.part),
2015 }
2016 }
2017 self.note_writes(applied)
2018 }
2019
2020 pub fn recover_txn_lines(&mut self, entries: &[(u64, String)]) -> Result<usize, TableError> {
2025 let missing: Vec<&(u64, String)> = entries.iter().filter(|(seq, _)| *seq > self.seq).collect();
2026 if missing.is_empty() {
2027 return Ok(0);
2028 }
2029 let applied = missing.len();
2030 let mut pending: Vec<(String, LogEntry, String)> = Vec::with_capacity(applied);
2032 for (_, line) in &missing {
2033 let path = self.logs.path_of(UNPARTITIONED).to_string_lossy().into_owned();
2034 let parsed = parse_entry(line, &path, 0)
2035 .map_err(|error| TableError::Io(io::Error::new(io::ErrorKind::InvalidData, error)))?;
2036 let part = match (&parsed.meta.op, &parsed.data) {
2037 (LogOp::Put, Some(record)) => partition_of(&self.schema, record)?,
2038 (LogOp::Put, None) => {
2039 return Err(TableError::Io(io::Error::new(
2040 io::ErrorKind::InvalidData,
2041 format!("journalled put for \"{}\" carries no record", parsed.meta.key),
2042 )))
2043 }
2044 (LogOp::Del, _) => self
2045 .registry
2046 .part_of(&parsed.meta.key)
2047 .unwrap_or(UNPARTITIONED)
2048 .to_string(),
2049 };
2050 pending.push((part, parsed, line.clone()));
2051 }
2052
2053 for (part, parsed, line) in pending {
2054 let slot = self.logs.append(&part, std::slice::from_ref(&line))?[0];
2055 self.cache.invalidate(&parsed.meta.key);
2056 match parsed.data {
2057 Some(record) => self
2058 .registry
2059 .apply_put_in(&parsed.meta.key, &record, slot, parsed.meta.seq, &part),
2060 None => self
2061 .registry
2062 .apply_delete_in(&parsed.meta.key, slot.length, parsed.meta.seq, &part),
2063 }
2064 self.seq = self.seq.max(parsed.meta.seq);
2065 }
2066 self.logs.sync()?;
2067 self.snapshot_current = false;
2069 self.snapshot()?;
2070 Ok(applied)
2071 }
2072}
2073
2074fn rewritten(log: &LogFile, probe: &LogProbe) -> bool {
2079 match (log.inode(), probe.inode) {
2080 (Some(held), Some(found)) if held != found => true,
2081 _ => probe.size < log.size(),
2082 }
2083}
2084
2085fn ordered_parts(entries: &[PreparedTxnEntry]) -> Vec<String> {
2088 let mut parts: Vec<String> = Vec::new();
2089 for entry in entries {
2090 if !parts.iter().any(|part| part == &entry.part) {
2091 parts.push(entry.part.clone());
2092 }
2093 }
2094 parts
2095}
2096
2097fn partition_dir_of(log_path: &Path, table: &str) -> PathBuf {
2100 match log_path.parent() {
2101 Some(parent) => parent.join(table),
2102 None => PathBuf::from(table),
2103 }
2104}
2105
2106fn open_logs(schema: &TableSchema, path: &Path, readonly: bool) -> io::Result<LogSet> {
2117 if !is_partitioned(schema) {
2118 return LogSet::flat(path, readonly);
2119 }
2120 let partition_dir = partition_dir_of(path, &schema.table);
2121 if readonly {
2122 if partition_names_in(&partition_dir).is_empty() && path.exists() {
2123 return LogSet::flat(path, true);
2124 }
2125 return LogSet::partitioned(partition_dir, true);
2126 }
2127 ensure_partitioned(path, &partition_dir, schema)?;
2128 LogSet::partitioned(partition_dir, readonly)
2129}
2130
2131pub enum TxnOp {
2133 Put(Value),
2134 Patch(String, Value),
2135 Delete(String),
2136}
2137
2138pub struct PreparedTxnEntry {
2141 key: String,
2142 seq: u64,
2143 line: String,
2144 old: Option<HaysonRecord>,
2145 record: Option<HaysonRecord>,
2146 part: String,
2148}
2149
2150impl PreparedTxnEntry {
2151 pub fn seq(&self) -> u64 {
2152 self.seq
2153 }
2154
2155 pub fn line(&self) -> &str {
2156 &self.line
2157 }
2158
2159 pub fn old(&self) -> Option<&HaysonRecord> {
2160 self.old.as_ref()
2161 }
2162
2163 pub fn record(&self) -> Option<&HaysonRecord> {
2164 self.record.as_ref()
2165 }
2166
2167 pub fn upsert_event(&self) -> crate::catalog::TriggerEvent {
2171 if self.old.is_some() {
2172 crate::catalog::TriggerEvent::Update
2173 } else {
2174 crate::catalog::TriggerEvent::Insert
2175 }
2176 }
2177}
2178
2179#[cfg(test)]
2180mod tests {
2181 use serde_json::json;
2182 use tempfile::tempdir;
2183
2184 use super::*;
2185 use crate::query::builder::QueryBuilder;
2186 use crate::query::predicates::ComparisonOp;
2187 use crate::schema::parse_schema;
2188
2189 fn users_schema() -> TableSchema {
2190 parse_schema(&json!({
2191 "schemaVersion": 1,
2192 "table": "users",
2193 "primaryKey": "id",
2194 "fields": {
2195 "id": { "type": "string" },
2196 "name": { "type": "string", "required": true },
2197 "age": { "type": "int", "index": "sorted" },
2198 "email": { "type": "string", "unique": true },
2199 },
2200 }))
2201 .unwrap()
2202 }
2203
2204 fn open_table() -> (tempfile::TempDir, Table) {
2205 let dir = tempdir().unwrap();
2206 let path = dir.path().join("users.jsonl");
2207 let table = Table::open(users_schema(), &path).unwrap();
2208 (dir, table)
2209 }
2210
2211 #[test]
2212 fn a_table_set_to_sync_never_flushes_its_writes_without_a_single_fsync() {
2213 let (_dir, mut table) = open_table();
2214 table.set_sync(CatalogSyncMode::Never);
2215
2216 table
2217 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2218 .unwrap();
2219 table
2220 .put(&json!({ "id": "u2", "name": "Piotr", "age": 41 }), false)
2221 .unwrap();
2222 table.flush().unwrap();
2223
2224 assert_eq!(table.sync_mode(), CatalogSyncMode::Never);
2225 assert_eq!(table.logs.log_of(UNPARTITIONED).unwrap().fsyncs(), 0);
2226 assert_eq!(table.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
2227 assert_eq!(table.get("u2").unwrap().unwrap().get("name"), Some(&json!("Piotr")));
2228 }
2229
2230 #[test]
2231 fn a_table_nobody_configured_still_fsyncs_every_flush() {
2232 let (_dir, mut table) = open_table();
2233
2234 assert_eq!(table.sync_mode(), CatalogSyncMode::Commit);
2235 table
2236 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2237 .unwrap();
2238 table.flush().unwrap();
2239
2240 assert_eq!(table.logs.log_of(UNPARTITIONED).unwrap().fsyncs(), 1);
2241 }
2242
2243 #[test]
2244 fn history_hands_back_every_version_of_one_key_oldest_first() {
2245 let (dir, mut table) = open_table();
2246 table
2247 .put(
2248 &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "a@example.test" }),
2249 false,
2250 )
2251 .unwrap();
2252 table
2253 .put(
2254 &json!({ "id": "u2", "name": "Piotr", "age": 41, "email": "p@example.test" }),
2255 false,
2256 )
2257 .unwrap();
2258 table.patch("u1", &json!({ "age": 31 }), false).unwrap();
2259 table.delete("u1", false).unwrap();
2260 table
2261 .put(
2262 &json!({ "id": "u1", "name": "Ania", "age": 32, "email": "a@example.test" }),
2263 false,
2264 )
2265 .unwrap();
2266
2267 let history = table.history("u1").unwrap();
2268
2269 assert_eq!(history.scanned, vec![dir.path().join("users.jsonl")]);
2270 let shape: Vec<(u64, LogOp, Option<&Value>)> = history
2271 .entries
2272 .iter()
2273 .map(|entry| (entry.seq, entry.op, entry.document.as_ref().and_then(|d| d.get("age"))))
2274 .collect();
2275 assert_eq!(
2276 shape,
2277 vec![
2278 (1, LogOp::Put, Some(&json!(30))),
2279 (3, LogOp::Put, Some(&json!(31))),
2280 (4, LogOp::Del, None),
2281 (5, LogOp::Put, Some(&json!(32))),
2282 ]
2283 );
2284 assert!(history.entries.iter().all(|entry| entry.partition == UNPARTITIONED));
2285 assert!(history.entries.iter().all(|entry| entry.ts > 0));
2286 assert_eq!(table.history("u2").unwrap().entries.len(), 1);
2287 assert!(table.history("nobody").unwrap().entries.is_empty());
2288 }
2289
2290 #[test]
2291 fn history_reads_the_line_the_offsets_it_reports_point_at() {
2292 let (_dir, mut table) = open_table();
2293 table
2294 .put(
2295 &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "a@example.test" }),
2296 false,
2297 )
2298 .unwrap();
2299 table.patch("u1", &json!({ "age": 31 }), false).unwrap();
2300
2301 let history = table.history("u1").unwrap();
2302 let log = table.logs.log_of(UNPARTITIONED).unwrap();
2303
2304 for entry in &history.entries {
2305 let slot = Slot {
2306 offset: entry.offset,
2307 length: entry.length,
2308 };
2309 let line: Value = serde_json::from_str(&log.read_slice(&slot).unwrap()).unwrap();
2310 assert_eq!(line.get("_seq"), Some(&json!(entry.seq)));
2311 }
2312 }
2313
2314 #[test]
2315 fn history_does_not_see_a_write_that_is_still_staged() {
2316 let (_dir, mut table) = open_table();
2317 table
2318 .stage_put(
2319 &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "a@example.test" }),
2320 false,
2321 )
2322 .unwrap();
2323
2324 assert!(table.history("u1").unwrap().entries.is_empty());
2325
2326 table.flush().unwrap();
2327
2328 assert_eq!(table.history("u1").unwrap().entries.len(), 1);
2329 }
2330
2331 #[test]
2332 fn a_table_nobody_configured_refuses_a_write_naming_an_undeclared_field() {
2333 let (_dir, mut table) = open_table();
2334 assert_eq!(table.validation(), CatalogValidation::Strict);
2335
2336 let error = table
2337 .put(&json!({ "id": "u1", "name": "Ania", "nickname": "Anka" }), false)
2338 .unwrap_err();
2339
2340 assert!(error.to_string().contains("nickname"), "{error}");
2341 assert!(error.to_string().contains("is not declared in the schema"), "{error}");
2342 assert_eq!(table.get("u1").unwrap(), None);
2343 }
2344
2345 #[test]
2346 fn a_warn_table_takes_the_write_drops_the_undeclared_field_and_says_which() {
2347 let dir = tempdir().unwrap();
2348 let path = dir.path().join("users.jsonl");
2349 let mut table = Table::open(users_schema(), &path).unwrap();
2350 table.set_validation(CatalogValidation::Warn);
2351 let document = json!({ "id": "u1", "name": "Ania", "age": 30, "nickname": "Anka" });
2352
2353 let record = table.put(&document, false).unwrap();
2354
2355 assert_eq!(record.get("name"), Some(&json!("Ania")));
2356 assert_eq!(record.get("nickname"), None);
2357 assert_eq!(table.get("u1").unwrap().unwrap().get("nickname"), None);
2358 assert_eq!(
2359 table.dropped_field_warnings(&document),
2360 vec!["field \"nickname\": is not declared in the schema, dropped from the write".to_string()]
2361 );
2362 let written = fs::read_to_string(&path).unwrap();
2363 assert!(!written.contains("nickname"), "{written}");
2364 assert!(!written.contains("Anka"), "{written}");
2365 }
2366
2367 #[test]
2368 fn a_warn_table_still_refuses_a_bad_type_a_missing_required_field_and_a_reserved_key() {
2369 let (_dir, mut table) = open_table();
2370 table.set_validation(CatalogValidation::Warn);
2371
2372 let bad_type = table
2373 .put(&json!({ "id": "u1", "name": "Ania", "age": "thirty" }), false)
2374 .unwrap_err();
2375 let missing = table.put(&json!({ "id": "u2" }), false).unwrap_err();
2376 let reserved = table
2377 .put(&json!({ "id": "u3", "name": "Ola", "_seq": 7 }), false)
2378 .unwrap_err();
2379
2380 assert!(bad_type.to_string().contains("expected an integer"), "{bad_type}");
2381 assert!(missing.to_string().contains("is required"), "{missing}");
2382 assert!(reserved.to_string().contains("reserved"), "{reserved}");
2383 }
2384
2385 #[test]
2386 fn a_warn_patch_merges_the_declared_changes_and_drops_the_undeclared_one() {
2387 let (_dir, mut table) = open_table();
2388 table.set_validation(CatalogValidation::Warn);
2389 table
2390 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2391 .unwrap();
2392
2393 let record = table
2394 .patch("u1", &json!({ "age": 31, "nickname": "Anka" }), false)
2395 .unwrap();
2396
2397 assert_eq!(record.get("age"), Some(&json!(31)));
2398 assert_eq!(record.get("nickname"), None);
2399 assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(31)));
2400 }
2401
2402 #[test]
2403 fn a_warn_table_prepares_a_unit_of_work_line_without_the_undeclared_field() {
2404 let (_dir, mut table) = open_table();
2405 table.set_validation(CatalogValidation::Warn);
2406
2407 let prepared = table
2408 .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Ania", "nickname": "Anka" }))])
2409 .unwrap();
2410
2411 assert_eq!(prepared.len(), 1);
2412 assert!(!prepared[0].line.contains("nickname"), "{}", prepared[0].line);
2413 assert_eq!(prepared[0].record.as_ref().unwrap().get("nickname"), None);
2414 }
2415
2416 #[test]
2417 fn dropped_field_warnings_answer_nothing_on_a_strict_table_or_a_document_with_no_stray_field() {
2418 let (_dir, mut table) = open_table();
2419 let document = json!({ "id": "u1", "name": "Ania", "nickname": "Anka" });
2420
2421 assert!(table.dropped_field_warnings(&document).is_empty());
2422
2423 table.set_validation(CatalogValidation::Warn);
2424 assert!(table
2425 .dropped_field_warnings(&json!({ "id": "u1", "name": "Ania" }))
2426 .is_empty());
2427 assert_eq!(table.dropped_field_warnings(&document).len(), 1);
2428 }
2429
2430 fn deprecating(age: &str) -> BTreeMap<String, String> {
2431 BTreeMap::from([("age".to_string(), age.to_string())])
2432 }
2433
2434 #[test]
2435 fn deprecated_field_warnings_name_the_marked_field_the_document_touches_and_quote_its_note() {
2436 let (_dir, mut table) = open_table();
2437 table.set_deprecated_fields(deprecating("use \"bornAt\" instead"));
2438
2439 table
2440 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2441 .unwrap();
2442
2443 assert_eq!(
2444 table.deprecated_field_warnings(&json!({ "id": "u1", "name": "Ania", "age": 30 })),
2445 vec!["field \"age\": is deprecated — use \"bornAt\" instead".to_string()]
2446 );
2447 assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(30)));
2448 }
2449
2450 #[test]
2451 fn deprecated_field_warnings_stay_quiet_for_a_document_touching_no_marked_field() {
2452 let (_dir, mut table) = open_table();
2453 table.set_deprecated_fields(deprecating("use \"bornAt\" instead"));
2454
2455 assert!(table
2456 .deprecated_field_warnings(&json!({ "id": "u1", "name": "Ania" }))
2457 .is_empty());
2458 assert!(table.deprecated_field_warnings(&json!({ "name": "Ola" })).is_empty());
2459 }
2460
2461 #[test]
2464 fn a_field_handed_over_with_an_empty_note_warns_with_the_field_alone() {
2465 let (_dir, mut table) = open_table();
2466 table.set_deprecated_fields(deprecating(""));
2467
2468 assert_eq!(
2469 table.deprecated_field_warnings(&json!({ "id": "u1", "age": 30 })),
2470 vec!["field \"age\": is deprecated".to_string()]
2471 );
2472 }
2473
2474 #[test]
2475 fn deprecated_field_warnings_answer_the_same_in_either_validation_mode_and_on_every_write() {
2476 let (_dir, mut table) = open_table();
2477 table.set_deprecated_fields(deprecating("use \"bornAt\" instead"));
2478 let document = json!({ "id": "u1", "name": "Ania", "age": 30 });
2479 let expected = vec!["field \"age\": is deprecated — use \"bornAt\" instead".to_string()];
2480
2481 assert_eq!(table.validation(), CatalogValidation::Strict);
2482 table.put(&document, false).unwrap();
2483 assert_eq!(table.deprecated_field_warnings(&document), expected);
2484
2485 table.set_validation(CatalogValidation::Warn);
2486 table.put(&document, false).unwrap();
2487 assert_eq!(table.deprecated_field_warnings(&document), expected);
2488 assert!(table.dropped_field_warnings(&document).is_empty());
2489 }
2490
2491 #[test]
2492 fn a_table_nobody_marked_answers_no_deprecation_warning_at_all() {
2493 let (_dir, table) = open_table();
2494
2495 assert!(table.deprecated_fields().is_empty());
2496 assert!(table
2497 .deprecated_field_warnings(&json!({ "id": "u1", "name": "Ania", "age": 30 }))
2498 .is_empty());
2499 }
2500
2501 #[test]
2502 fn what_a_sync_never_table_wrote_is_still_there_after_reopening_it() {
2503 let dir = tempdir().unwrap();
2504 let path = dir.path().join("users.jsonl");
2505 {
2506 let mut table = Table::open(users_schema(), &path).unwrap();
2507 table.set_sync(CatalogSyncMode::Never);
2508 table
2509 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2510 .unwrap();
2511 table.flush().unwrap();
2512 }
2513
2514 let reopened = Table::open(users_schema(), &path).unwrap();
2515 assert_eq!(reopened.get("u1").unwrap().unwrap().get("age"), Some(&json!(30)));
2516 }
2517
2518 #[test]
2519 fn put_then_get_round_trips_the_record() {
2520 let (_dir, mut table) = open_table();
2521 table
2522 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2523 .unwrap();
2524
2525 let fetched = table.get("u1").unwrap().unwrap();
2526 assert_eq!(fetched.get("name"), Some(&json!("Ania")));
2527 assert_eq!(fetched.get("age"), Some(&json!(30)));
2528 }
2529
2530 #[test]
2531 fn get_of_an_unknown_key_is_none() {
2532 let (_dir, table) = open_table();
2533 assert_eq!(table.get("missing").unwrap(), None);
2534 }
2535
2536 #[test]
2537 fn put_delete_get_leaves_nothing_behind() {
2538 let (_dir, mut table) = open_table();
2539 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2540 let existed = table.delete("u1", false).unwrap();
2541 assert!(existed);
2542 assert_eq!(table.get("u1").unwrap(), None);
2543 }
2544
2545 #[test]
2546 fn delete_of_an_unknown_key_reports_it_did_not_exist() {
2547 let (_dir, mut table) = open_table();
2548 let existed = table.delete("ghost", false).unwrap();
2549 assert!(!existed);
2550 }
2551
2552 #[test]
2553 fn patch_merges_only_the_given_fields() {
2554 let (_dir, mut table) = open_table();
2555 table
2556 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2557 .unwrap();
2558
2559 let patched = table.patch("u1", &json!({ "age": 31 }), false).unwrap();
2560 assert_eq!(patched.get("name"), Some(&json!("Ania")));
2561 assert_eq!(patched.get("age"), Some(&json!(31)));
2562
2563 let fetched = table.get("u1").unwrap().unwrap();
2564 assert_eq!(fetched, patched);
2565 }
2566
2567 fn self_audit_trigger() -> TriggerEntry {
2568 TriggerEntry {
2569 name: "users_insert_self_audit".into(),
2570 table: "users".into(),
2571 event: TriggerEvent::Insert,
2572 effect: crate::catalog::TriggerEffect {
2573 table: "users".into(),
2574 fields: vec![
2575 crate::catalog::TriggerFieldMapping {
2576 target: "id".into(),
2577 source: crate::catalog::TriggerValueSource::Constant(json!("audit-u1")),
2578 },
2579 crate::catalog::TriggerFieldMapping {
2580 target: "name".into(),
2581 source: crate::catalog::TriggerValueSource::NewField("name".into()),
2582 },
2583 ],
2584 },
2585 }
2586 }
2587
2588 #[test]
2589 fn a_live_put_fires_a_same_table_trigger_right_after_the_write_it_fired_on() {
2590 let (_dir, mut table) = open_table();
2591 table.set_triggers(vec![self_audit_trigger()]);
2592
2593 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2594
2595 assert!(table.get("u1").unwrap().is_some());
2596 let effect = table.get("audit-u1").unwrap().unwrap();
2597 assert_eq!(effect.get("name"), Some(&json!("Ania")));
2598 }
2599
2600 #[test]
2601 fn a_trigger_only_fires_for_the_event_it_declares() {
2602 let (_dir, mut table) = open_table();
2603 table.set_triggers(vec![self_audit_trigger()]);
2604
2605 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2606 table.patch("u1", &json!({ "name": "Anna" }), false).unwrap();
2607
2608 assert_eq!(
2610 table.get("audit-u1").unwrap().unwrap().get("name"),
2611 Some(&json!("Ania"))
2612 );
2613 }
2614
2615 #[test]
2616 fn patch_of_an_unknown_key_is_an_error() {
2617 let (_dir, mut table) = open_table();
2618 let err = table.patch("ghost", &json!({ "age": 1 }), false).unwrap_err();
2619 assert!(matches!(err, TableError::NotFound(_)));
2620 }
2621
2622 #[test]
2623 fn patch_cannot_change_the_primary_key() {
2624 let (_dir, mut table) = open_table();
2625 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2626 let err = table.patch("u1", &json!({ "id": "u2" }), false).unwrap_err();
2627 assert!(matches!(err, TableError::Schema(_)));
2628 }
2629
2630 #[test]
2631 fn repeated_puts_on_the_same_key_leave_only_the_latest_version() {
2632 let (_dir, mut table) = open_table();
2633 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2634 table.put(&json!({ "id": "u1", "name": "Zosia" }), false).unwrap();
2635 table.put(&json!({ "id": "u1", "name": "Kasia" }), false).unwrap();
2636
2637 let fetched = table.get("u1").unwrap().unwrap();
2638 assert_eq!(fetched.get("name"), Some(&json!("Kasia")));
2639 }
2640
2641 #[test]
2644 fn a_search_against_a_table_with_no_fulltext_field_is_refused_rather_than_answered_empty() {
2645 let (_dir, mut table) = open_table();
2646 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2647
2648 let spec = QueryBuilder::new()
2649 .search("anything", crate::registry::fulltext_index::SearchMode::All)
2650 .spec();
2651 let refusal = table.query(&spec).unwrap_err();
2652
2653 assert!(
2654 matches!(&refusal, TableError::Schema(_)),
2655 "a table with no fulltext field is a schema refusal, not an io failure: {refusal:?}"
2656 );
2657 assert!(
2658 refusal.to_string().contains("has no field marked \"fulltext\""),
2659 "the message is the one the TS package raised: {refusal}"
2660 );
2661 }
2662
2663 #[test]
2664 fn query_delegates_to_the_planner_and_matches_it_directly() {
2665 let (_dir, mut table) = open_table();
2666 table
2667 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2668 .unwrap();
2669 table
2670 .put(&json!({ "id": "u2", "name": "Piotr", "age": 24 }), false)
2671 .unwrap();
2672
2673 let spec = QueryBuilder::new().r#where("age", ComparisonOp::Gt, json!(25)).spec();
2674 let via_table = table.query(&spec).unwrap();
2675
2676 let mut source = LogSetRecordSource::new(&table.registry, &table.logs);
2677 let direct = execute_query(&table.registry, &mut source, &spec).unwrap();
2678
2679 assert_eq!(via_table, direct);
2680 assert_eq!(via_table.keys, vec!["u1".to_string()]);
2681 }
2682
2683 #[test]
2684 fn aggregate_delegates_to_the_planner_and_matches_it_directly() {
2685 let (_dir, mut table) = open_table();
2686 table
2687 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2688 .unwrap();
2689 table
2690 .put(&json!({ "id": "u2", "name": "Piotr", "age": 24 }), false)
2691 .unwrap();
2692
2693 let (spec, aggregate) = QueryBuilder::new().count().sum("age").avg("age").build_aggregate();
2694 let via_table = table.aggregate(&spec, &aggregate).unwrap();
2695
2696 let mut source = LogSetRecordSource::new(&table.registry, &table.logs);
2697 let direct =
2698 crate::query::aggregate::execute_aggregate(&table.registry, &mut source, &spec, &aggregate).unwrap();
2699
2700 assert_eq!(via_table, direct);
2701 assert_eq!(via_table.count, 2);
2702 assert_eq!(via_table.sum["age"], 54.0);
2703 assert_eq!(via_table.avg["age"], Some(27.0));
2704 }
2705
2706 fn open_table_with_snapshot_every(snapshot_every: u64) -> (tempfile::TempDir, Table, std::path::PathBuf) {
2707 let dir = tempdir().unwrap();
2708 let log_path = dir.path().join("users.jsonl");
2709 let snapshot_path = dir.path().join("users.idx");
2710 let (table, _) =
2711 Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", snapshot_every)
2712 .unwrap();
2713 (dir, table, snapshot_path)
2714 }
2715
2716 #[test]
2717 fn writes_a_snapshot_once_the_operation_threshold_is_crossed() {
2718 let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(3);
2719 assert!(!snapshot_path.exists());
2720
2721 for n in 0..3 {
2722 table
2723 .put(
2724 &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2725 false,
2726 )
2727 .unwrap();
2728 }
2729
2730 let snapshot = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
2731 assert_eq!(snapshot.header.watermark, table.logs.total_size());
2732 assert_eq!(snapshot.entries.len(), 3);
2733 assert!(!table.needs_snapshot());
2734 }
2735
2736 #[test]
2737 fn a_snapshot_written_mid_stream_does_not_disturb_later_reads_and_writes() {
2738 let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(2);
2739
2740 table
2741 .put(
2742 &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
2743 false,
2744 )
2745 .unwrap();
2746 table
2747 .put(
2748 &json!({ "id": "u2", "name": "Piotr", "age": 24, "email": "u2@x.com" }),
2749 false,
2750 )
2751 .unwrap();
2752 assert!(snapshot_path.exists());
2753
2754 table
2755 .put(
2756 &json!({ "id": "u3", "name": "Kasia", "age": 40, "email": "u3@x.com" }),
2757 false,
2758 )
2759 .unwrap();
2760 let existed = table.delete("u1", false).unwrap();
2761 assert!(existed);
2762
2763 assert_eq!(table.get("u1").unwrap(), None);
2764 assert_eq!(table.get("u3").unwrap().unwrap().get("name"), Some(&json!("Kasia")));
2765 }
2766
2767 #[test]
2768 fn compaction_replaces_the_snapshot_with_one_that_pairs_with_the_rewritten_log() {
2769 let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(0);
2770 for n in 0..4 {
2771 table
2772 .put(
2773 &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2774 false,
2775 )
2776 .unwrap();
2777 }
2778 table.delete("u0", false).unwrap();
2779 table.delete("u1", false).unwrap();
2780 table.snapshot().unwrap();
2781 let stale = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
2782 assert!(
2783 stale.header.dead_bytes > 0,
2784 "the seed really did leave dead bytes behind"
2785 );
2786
2787 let result = table.compact().unwrap();
2788
2789 assert!(result.reclaimed_bytes > 0, "the compaction really did shrink the log");
2790 let fresh = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
2791 assert_eq!(fresh.header.watermark, table.logs.total_size());
2792 assert_eq!(
2793 fresh.header.watermark,
2794 fs::metadata(table.logs.log_of("").unwrap().path()).unwrap().len()
2795 );
2796 assert!(fresh.header.watermark < stale.header.watermark);
2797 assert_eq!(fresh.header.dead_bytes, 0);
2798 assert_eq!(fresh.entries.len(), 2);
2799 assert!(!table.needs_snapshot());
2800 }
2801
2802 #[test]
2803 fn the_generation_counts_compactions_and_survives_a_restart_through_the_snapshot_header() {
2804 let dir = tempdir().unwrap();
2805 let log_path = dir.path().join("users.jsonl");
2806 let snapshot_path = dir.path().join("users.idx");
2807
2808 {
2809 let (mut table, _) =
2810 Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2811 assert_eq!(
2812 table.stats().gen,
2813 0,
2814 "a table opened over a log with no snapshot starts at zero"
2815 );
2816 for n in 0..3 {
2817 table
2818 .put(
2819 &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2820 false,
2821 )
2822 .unwrap();
2823 }
2824 table.delete("u0", false).unwrap();
2825 table.compact().unwrap();
2826 assert_eq!(table.stats().gen, 1);
2827 table.compact().unwrap();
2828 assert_eq!(table.stats().gen, 2);
2829 assert_eq!(
2830 crate::checkpoint::read_snapshot(&snapshot_path)
2831 .unwrap()
2832 .unwrap()
2833 .header
2834 .gen,
2835 2,
2836 "the snapshot carries the generation it describes"
2837 );
2838 }
2839
2840 let (mut reopened, outcome) =
2841 Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2842 assert_eq!(outcome, RestoreOutcome::Restored);
2843 assert_eq!(
2844 reopened.stats().gen,
2845 2,
2846 "the restart carries the generation over instead of starting again"
2847 );
2848 reopened.compact().unwrap();
2849 assert_eq!(reopened.stats().gen, 3);
2850 assert_eq!(
2851 crate::checkpoint::read_snapshot(&snapshot_path)
2852 .unwrap()
2853 .unwrap()
2854 .header
2855 .gen,
2856 3
2857 );
2858 }
2859
2860 #[test]
2864 fn a_snapshot_written_before_the_header_carried_a_generation_still_restores_and_starts_at_zero() {
2865 let dir = tempdir().unwrap();
2866 let log_path = dir.path().join("users.jsonl");
2867 let snapshot_path = dir.path().join("users.idx");
2868
2869 {
2870 let (mut table, _) =
2871 Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2872 for n in 0..3 {
2873 table
2874 .put(
2875 &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2876 false,
2877 )
2878 .unwrap();
2879 }
2880 table.compact().unwrap();
2881 assert_eq!(table.stats().gen, 1);
2882 }
2883
2884 let written = fs::read_to_string(&snapshot_path).unwrap();
2885 let (header_line, entry_lines) = written.split_once('\n').unwrap();
2886 let mut header: serde_json::Map<String, Value> = serde_json::from_str(header_line).unwrap();
2887 assert!(
2888 header.remove("gen").is_some(),
2889 "the header this engine writes carries the field to remove"
2890 );
2891 fs::write(
2892 &snapshot_path,
2893 format!("{}\n{entry_lines}", serde_json::to_string(&header).unwrap()),
2894 )
2895 .unwrap();
2896
2897 let (mut reopened, outcome) =
2898 Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2899 assert_eq!(
2900 outcome,
2901 RestoreOutcome::Restored,
2902 "an old snapshot is still a usable cache"
2903 );
2904 assert_eq!(reopened.len(), 3);
2905 assert_eq!(reopened.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
2906 assert_eq!(reopened.stats().gen, 0);
2907 reopened.compact().unwrap();
2908 assert_eq!(
2909 reopened.stats().gen,
2910 1,
2911 "counting resumes from the default, and lands in the rewritten header"
2912 );
2913 assert_eq!(
2914 crate::checkpoint::read_snapshot(&snapshot_path)
2915 .unwrap()
2916 .unwrap()
2917 .header
2918 .gen,
2919 1
2920 );
2921 }
2922
2923 #[test]
2924 fn reads_agree_before_and_after_a_restart_that_follows_a_compaction() {
2925 let dir = tempdir().unwrap();
2926 let log_path = dir.path().join("users.jsonl");
2927 let snapshot_path = dir.path().join("users.idx");
2928 let spec = QueryBuilder::new()
2929 .r#where("name", ComparisonOp::Eq, json!("Ania"))
2930 .spec();
2931
2932 let (before, after_compaction) = {
2933 let (mut table, _) =
2934 Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2935 for n in 0..5 {
2936 table
2937 .put(
2938 &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2939 false,
2940 )
2941 .unwrap();
2942 }
2943 table.patch("u2", &json!({ "age": 31 }), false).unwrap();
2944 table.delete("u4", false).unwrap();
2945 table.snapshot().unwrap();
2946 let before = table.query(&spec).unwrap().keys;
2947
2948 table.compact().unwrap();
2949 let after = table.query(&spec).unwrap().keys;
2950 (before, after)
2951 };
2952
2953 let (reopened, outcome) =
2954 Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2955 assert_eq!(
2956 outcome,
2957 RestoreOutcome::Restored,
2958 "the snapshot compaction wrote pairs with the new log"
2959 );
2960 assert_eq!(
2961 after_compaction, before,
2962 "compaction changed neither the set nor the order of live keys"
2963 );
2964 assert_eq!(reopened.query(&spec).unwrap().keys, before);
2965 assert_eq!(reopened.len(), 4);
2966 assert_eq!(reopened.get("u2").unwrap().unwrap().get("age"), Some(&json!(31)));
2967 assert_eq!(reopened.get("u4").unwrap(), None);
2968 }
2969
2970 #[test]
2971 fn zero_disables_periodic_snapshotting() {
2972 let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(0);
2973 for n in 0..10 {
2974 table
2975 .put(
2976 &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2977 false,
2978 )
2979 .unwrap();
2980 }
2981 assert!(!snapshot_path.exists());
2982 assert!(table.needs_snapshot());
2983 }
2984
2985 #[test]
2986 fn a_table_opened_without_a_snapshot_path_never_writes_one() {
2987 let (_dir, mut table) = open_table();
2988 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2989 table.snapshot().unwrap();
2990 assert!(!table.needs_snapshot());
2991 }
2992
2993 fn two_handles() -> (tempfile::TempDir, Table, Table) {
2996 let dir = tempdir().unwrap();
2997 let path = dir.path().join("users.jsonl");
2998 let writer = Table::open(users_schema(), &path).unwrap();
2999 let follower = Table::open(users_schema(), &path).unwrap();
3000 (dir, writer, follower)
3001 }
3002
3003 #[test]
3004 fn refresh_on_a_log_nobody_touched_reports_no_change_and_leaves_the_registry_alone() {
3005 let (_dir, mut table) = open_table();
3006 table
3007 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3008 .unwrap();
3009
3010 let first = table.refresh().unwrap();
3011 let second = table.refresh().unwrap();
3012
3013 assert_eq!(first, RefreshOutcome::Unchanged);
3014 assert_eq!(
3015 second,
3016 RefreshOutcome::Unchanged,
3017 "a handle is not behind its own writes"
3018 );
3019 assert!(!first.changed());
3020 assert_eq!(table.len(), 1);
3021 assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(30)));
3022 }
3023
3024 #[test]
3025 fn refresh_applies_the_records_another_handle_appended_to_the_same_log() {
3026 let (_dir, mut writer, mut follower) = two_handles();
3027 writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3028 assert_eq!(
3029 follower.get("u1").unwrap(),
3030 None,
3031 "a handle sees nothing until it looks again"
3032 );
3033
3034 let outcome = follower.refresh().unwrap();
3035
3036 assert_eq!(outcome, RefreshOutcome::CaughtUp { entries: 1 });
3037 assert_eq!(follower.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
3038 assert_eq!(follower.len(), 1);
3039 assert_eq!(follower.stats().seq, writer.stats().seq);
3040 }
3041
3042 #[test]
3043 fn refresh_applies_a_foreign_delete_as_well_as_a_foreign_write() {
3044 let (_dir, mut writer, mut follower) = two_handles();
3045 writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3046 writer.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3047 follower.refresh().unwrap();
3048 writer.delete("u1", false).unwrap();
3049
3050 assert_eq!(follower.refresh().unwrap(), RefreshOutcome::CaughtUp { entries: 1 });
3051 assert!(!follower.has("u1"));
3052 assert!(follower.has("u2"));
3053 assert_eq!(follower.len(), 1);
3054 }
3055
3056 #[test]
3057 fn refresh_rebuilds_after_another_handle_compacted_the_log_under_it() {
3058 let (_dir, mut writer, mut follower) = two_handles();
3059 writer
3060 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3061 .unwrap();
3062 writer.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3063 writer.patch("u1", &json!({ "age": 31 }), false).unwrap();
3064 writer.delete("u2", false).unwrap();
3065 follower.refresh().unwrap();
3066 let before = follower.stats().log_bytes;
3067
3068 writer.compact().unwrap();
3069
3070 let outcome = follower.refresh().unwrap();
3071 assert_eq!(outcome, RefreshOutcome::Reloaded);
3072 assert!(
3073 follower.stats().log_bytes < before,
3074 "the compaction really did shrink the file"
3075 );
3076 assert_eq!(follower.len(), 1);
3077 assert_eq!(follower.get("u1").unwrap().unwrap().get("age"), Some(&json!(31)));
3078 assert_eq!(follower.get("u2").unwrap(), None);
3079 }
3080
3081 #[cfg(unix)]
3086 #[test]
3087 fn refresh_catches_a_foreign_compaction_even_when_the_log_ends_up_longer_than_before() {
3088 let (_dir, mut writer, mut follower) = two_handles();
3089 for round in 0..6 {
3090 writer
3091 .put(&json!({ "id": "u1", "name": format!("round {round}") }), false)
3092 .unwrap();
3093 }
3094 follower.refresh().unwrap();
3095 let watermark = follower.stats().log_bytes;
3096
3097 writer.compact().unwrap();
3098 for id in ["u2", "u3", "u4", "u5", "u6"] {
3099 writer
3100 .put(&json!({ "id": id, "name": "after the compaction" }), false)
3101 .unwrap();
3102 }
3103 assert!(
3104 writer.stats().log_bytes > watermark,
3105 "the log grew past where the follower stood"
3106 );
3107
3108 let outcome = follower.refresh().unwrap();
3109
3110 assert_eq!(outcome, RefreshOutcome::Reloaded);
3111 assert_eq!(follower.len(), 6);
3112 assert_eq!(
3113 follower.get("u1").unwrap().unwrap().get("name"),
3114 Some(&json!("round 5"))
3115 );
3116 assert_eq!(
3117 follower.get("u6").unwrap().unwrap().get("name"),
3118 Some(&json!("after the compaction"))
3119 );
3120 }
3121
3122 #[test]
3123 fn a_reload_keeps_serving_queries_from_the_declared_indexes_it_was_built_with() {
3124 let dir = tempdir().unwrap();
3125 let path = dir.path().join("users.jsonl");
3126 let declared = vec![ExpandedIndex {
3127 name: "users_name_email".into(),
3128 fields: vec!["name".into(), "email".into()],
3129 kind: crate::catalog::CatalogIndexKind::Exact,
3130 unique: false,
3131 where_: None,
3132 expire_after_seconds: None,
3133 }];
3134 let mut writer = Table::open_with_declared_indexes(users_schema(), &path, &declared).unwrap();
3135 let mut follower = Table::open_with_declared_indexes(users_schema(), &path, &declared).unwrap();
3136 writer
3137 .put(&json!({ "id": "u1", "name": "Ania", "email": "a@x.pl" }), false)
3138 .unwrap();
3139 follower.refresh().unwrap();
3140 writer.compact().unwrap();
3141
3142 assert_eq!(follower.refresh().unwrap(), RefreshOutcome::Reloaded);
3143 let spec = QueryBuilder::new()
3144 .r#where("name", ComparisonOp::Eq, json!("Ania"))
3145 .r#where("email", ComparisonOp::Eq, json!("a@x.pl"))
3146 .spec();
3147 let found = follower.query(&spec).unwrap();
3148 assert_eq!(found.keys, vec!["u1".to_string()]);
3149 assert_eq!(
3150 found.plan.index.as_deref(),
3151 Some("users_name_email"),
3152 "a rebuilt registry kept the declared index"
3153 );
3154 }
3155
3156 #[test]
3157 fn refresh_opens_a_partition_file_another_handle_created_and_reads_it_whole() {
3158 let dir = tempdir().unwrap();
3159 let path = dir.path().join("notes.jsonl");
3160 let schema = parse_schema(&json!({
3161 "schemaVersion": 1,
3162 "table": "notes",
3163 "primaryKey": "id",
3164 "partitionBy": "kind",
3165 "fields": { "id": { "type": "string" }, "kind": { "type": "string", "required": true } },
3166 }))
3167 .unwrap();
3168 let mut writer = Table::open(schema.clone(), &path).unwrap();
3169 let mut follower = Table::open(schema, &path).unwrap();
3170 writer.put(&json!({ "id": "a1", "kind": "adr" }), false).unwrap();
3171 writer.put(&json!({ "id": "n1", "kind": "note" }), false).unwrap();
3172
3173 let outcome = follower.refresh().unwrap();
3174
3175 assert_eq!(outcome, RefreshOutcome::CaughtUp { entries: 2 });
3176 let mut keys: Vec<&String> = follower.keys().collect();
3177 keys.sort();
3178 assert_eq!(keys, vec!["a1", "n1"]);
3179 }
3180
3181 #[test]
3185 fn follow_is_due_only_where_follow_if_due_would_reach_for_the_files() {
3186 let (_dir, mut writer, mut follower) = two_handles();
3187 assert!(!follower.follow_is_due(), "no policy is the writable handle's default");
3188 assert_eq!(follower.follow_if_due().unwrap(), None);
3189
3190 follower.set_follow(Some(FollowPolicy {
3191 interval: Duration::ZERO,
3192 }));
3193 assert!(follower.follow_is_due(), "a zero interval is due on every read");
3194
3195 follower.set_follow(Some(FollowPolicy {
3196 interval: Duration::from_secs(60),
3197 }));
3198 assert!(
3199 follower.follow_is_due(),
3200 "and a handle that never looked is due whatever it is"
3201 );
3202 writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3203 assert_eq!(
3204 follower.follow_if_due().unwrap(),
3205 Some(RefreshOutcome::CaughtUp { entries: 1 })
3206 );
3207
3208 assert!(
3209 !follower.follow_is_due(),
3210 "the interval has not elapsed since that catch-up"
3211 );
3212 writer.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3213 assert_eq!(follower.follow_if_due().unwrap(), None);
3214 assert!(!follower.has("u2"), "which is the whole point of the interval");
3215 }
3216
3217 #[test]
3220 fn has_staged_writes_answers_whether_a_flush_would_do_anything() {
3221 let (_dir, mut table) = open_table();
3222 assert!(!table.has_staged_writes(), "a table nobody staged against");
3223
3224 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3225 assert!(!table.has_staged_writes(), "put stages and flushes inside the one call");
3226
3227 table.stage_put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3228 assert!(table.has_staged_writes());
3229 assert_eq!(table.stats().queue_depth, 1);
3230
3231 table.flush().unwrap();
3232 assert!(
3233 !table.has_staged_writes(),
3234 "and the window is empty again once it lands"
3235 );
3236 }
3237
3238 #[test]
3239 fn keys_has_len_and_stats_report_the_live_records_not_the_bytes_behind_them() {
3240 let (_dir, mut table) = open_table();
3241 assert!(table.is_empty());
3242 table
3243 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3244 .unwrap();
3245 table.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3246 table.patch("u1", &json!({ "age": 31 }), false).unwrap();
3247 table.delete("u2", false).unwrap();
3248
3249 let mut keys: Vec<&String> = table.keys().collect();
3250 keys.sort();
3251 let stats = table.stats();
3252
3253 assert_eq!(keys, vec!["u1"]);
3254 assert_eq!(table.len(), 1, "a tombstoned key is not a record");
3255 assert!(!table.is_empty());
3256 assert!(table.has("u1"));
3257 assert!(!table.has("u2"));
3258 assert!(!table.has("never-written"));
3259 assert_eq!(stats.records, 1);
3260 assert!(
3261 stats.dead_bytes > 0,
3262 "the superseded version and the tombstone are dead bytes"
3263 );
3264 assert_eq!(stats.log_bytes, stats.live_bytes + stats.dead_bytes);
3265 assert_eq!(stats.seq, 4, "one seq per appended line, tombstone included");
3266 assert_eq!(stats.gen, 0);
3267 }
3268
3269 #[test]
3270 fn pending_is_empty_between_calls_because_every_write_commits_inside_its_own_call() {
3271 let (_dir, mut table) = open_table();
3272 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3273
3274 assert!(table.pending("u1").is_empty());
3275 assert!(table.pending("never-written").is_empty());
3276 assert_eq!(table.stats().queue_depth, 0);
3277 }
3278
3279 mod staging {
3283 use super::*;
3284
3285 #[test]
3286 fn a_staged_put_is_read_back_before_it_is_a_record() {
3287 let (_dir, mut table) = open_table();
3288 let staged = table.stage_put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3289
3290 assert_eq!(staged.get("name"), Some(&json!("Ania")));
3291 assert_eq!(table.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
3292 assert!(table.has("u1"));
3293 assert_eq!(table.len(), 0, "size counts the registry, never the window");
3294 assert!(table.keys().next().is_none(), "and so does keys()");
3295 assert_eq!(table.stats().queue_depth, 1);
3296 assert_eq!(
3297 table.pending("u1"),
3298 HashSet::from(["id".to_string(), "name".to_string()])
3299 );
3300
3301 table.flush().unwrap();
3302
3303 assert_eq!(table.len(), 1);
3304 assert_eq!(table.stats().queue_depth, 0);
3305 assert!(table.pending("u1").is_empty());
3306 }
3307
3308 #[test]
3309 fn a_staged_delete_answers_gone_before_its_tombstone_is_written() {
3310 let (_dir, mut table) = open_table();
3311 table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3312
3313 assert!(
3314 table.stage_delete("u1", false).unwrap(),
3315 "it says what it removed right away"
3316 );
3317 assert_eq!(table.get("u1").unwrap(), None);
3318 assert!(!table.has("u1"));
3319 assert_eq!(table.len(), 1, "the record is a record until the tombstone lands");
3320
3321 table.flush().unwrap();
3322
3323 assert_eq!(table.len(), 0);
3324 assert_eq!(table.get("u1").unwrap(), None);
3325 }
3326
3327 #[test]
3328 fn a_staged_write_outranks_a_peers_committed_one_on_the_same_key() {
3329 let (_dir, mut writer, mut follower) = two_handles();
3330 follower
3331 .stage_put(&json!({ "id": "u1", "name": "Ania" }), false)
3332 .unwrap();
3333 writer.put(&json!({ "id": "u1", "name": "Zosia" }), false).unwrap();
3334
3335 assert!(follower.refresh().unwrap().changed());
3336 assert_eq!(
3337 follower.get("u1").unwrap().unwrap().get("name"),
3338 Some(&json!("Ania")),
3339 "the foreign write landed in the registry, but the window is read first"
3340 );
3341
3342 follower.flush().unwrap();
3343
3344 assert_eq!(follower.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
3345 assert_eq!(follower.len(), 1);
3346 }
3347
3348 #[test]
3349 fn a_staged_patch_claims_the_fields_it_changes_and_no_others() {
3350 let (_dir, mut table) = open_table();
3351 table
3352 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3353 .unwrap();
3354
3355 table.stage_patch("u1", &json!({ "age": 31 }), false).unwrap();
3356 table.stage_patch("u1", &json!({ "name": "Anna" }), false).unwrap();
3357 assert_eq!(
3358 table.pending("u1"),
3359 HashSet::from(["age".to_string(), "name".to_string()])
3360 );
3361
3362 let refused = table.stage_patch("u1", &json!({ "age": 32 }), false).unwrap_err();
3363 assert!(matches!(refused, TableError::Conflict(_)));
3364
3365 table.stage_patch("u1", &json!({ "age": 44 }), true).unwrap();
3366 table.flush().unwrap();
3367
3368 let found = table.get("u1").unwrap().unwrap();
3369 assert_eq!(
3370 found.get("age"),
3371 Some(&json!(44)),
3372 "the forced patch is the one that stuck"
3373 );
3374 assert_eq!(
3375 found.get("name"),
3376 Some(&json!("Anna")),
3377 "and it merged over the other one"
3378 );
3379 }
3380
3381 #[test]
3382 fn a_read_of_committed_state_flushes_the_window_first() {
3383 let (_dir, mut table) = open_table();
3384 table
3385 .stage_put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3386 .unwrap();
3387
3388 let spec = QueryBuilder::new().r#where("age", ComparisonOp::Gt, json!(25)).spec();
3389 assert_eq!(table.query_following(&spec).unwrap().keys, vec!["u1".to_string()]);
3390 assert_eq!(table.stats().queue_depth, 0);
3391
3392 table
3393 .stage_put(&json!({ "id": "u2", "name": "Piotr", "age": 24 }), false)
3394 .unwrap();
3395 let (counted, aggregate) = QueryBuilder::new().count().build_aggregate();
3396 assert_eq!(table.aggregate_following(&counted, &aggregate).unwrap().count, 2);
3397 assert_eq!(table.stats().queue_depth, 0);
3398
3399 table
3400 .stage_put(&json!({ "id": "u3", "name": "Kasia", "age": 51 }), false)
3401 .unwrap();
3402 table.compact().unwrap();
3403
3404 assert_eq!(
3405 table.stats().queue_depth,
3406 0,
3407 "compaction would have rewritten the log without it"
3408 );
3409 assert_eq!(table.len(), 3);
3410 assert_eq!(table.get("u3").unwrap().unwrap().get("name"), Some(&json!("Kasia")));
3411 }
3412
3413 #[test]
3414 fn a_trigger_fires_on_the_flush_rather_than_on_the_staging() {
3415 let (_dir, mut table) = open_table();
3416 table.set_triggers(vec![self_audit_trigger()]);
3417
3418 table.stage_put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3419 assert_eq!(
3420 table.get("audit-u1").unwrap(),
3421 None,
3422 "an uncommitted write has no effect yet"
3423 );
3424
3425 table.flush().unwrap();
3426
3427 assert_eq!(
3428 table.get("audit-u1").unwrap().unwrap().get("name"),
3429 Some(&json!("Ania"))
3430 );
3431 }
3432 }
3433
3434 #[test]
3435 fn stats_counts_a_rebuild_as_a_generation() {
3436 let (_dir, mut writer, mut follower) = two_handles();
3437 writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3438 follower.refresh().unwrap();
3439 assert_eq!(follower.stats().gen, 0);
3440
3441 writer.compact().unwrap();
3442 follower.refresh().unwrap();
3443
3444 assert_eq!(follower.stats().gen, 1);
3445 }
3446
3447 fn docs_schema() -> TableSchema {
3448 parse_schema(&json!({
3449 "schemaVersion": 1,
3450 "table": "docs",
3451 "primaryKey": "id",
3452 "fields": {
3453 "id": { "type": "string" },
3454 "cover": { "type": "image" },
3455 },
3456 }))
3457 .unwrap()
3458 }
3459
3460 fn open_docs_table() -> (tempfile::TempDir, Table) {
3461 let dir = tempdir().unwrap();
3462 let path = dir.path().join("docs.jsonl");
3463 let table = Table::open(docs_schema(), &path).unwrap();
3464 (dir, table)
3465 }
3466
3467 #[test]
3468 fn a_put_with_an_image_field_writes_its_bytes_under_the_default_images_directory() {
3469 let (dir, mut table) = open_docs_table();
3470 let record = table
3471 .put(
3472 &json!({ "id": "d1", "cover": { "content": "bG9nby1ieXRlcw==", "virtualPath": "/images/logo.png" } }),
3473 false,
3474 )
3475 .unwrap();
3476 let physical_path = record
3477 .get("cover")
3478 .unwrap()
3479 .get("physicalPath")
3480 .unwrap()
3481 .as_str()
3482 .unwrap();
3483
3484 assert_eq!(table.image_dir(), dir.path().join("docs.images"));
3485 let on_disk = fs::read(table.image_dir().join(physical_path)).unwrap();
3486 assert_eq!(on_disk, b"logo-bytes");
3487 }
3488
3489 #[test]
3490 fn a_second_record_with_the_same_image_content_does_not_rewrite_the_existing_file() {
3491 let (_dir, mut table) = open_docs_table();
3492 table
3493 .put(
3494 &json!({ "id": "d1", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/a.png" } }),
3495 false,
3496 )
3497 .unwrap();
3498 let file_path = table.image_dir().join(physical_path_of(b"same-bytes"));
3499 let written_at = fs::metadata(&file_path).unwrap().modified().unwrap();
3500
3501 table
3502 .put(
3503 &json!({ "id": "d2", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/b.png" } }),
3504 false,
3505 )
3506 .unwrap();
3507
3508 assert_eq!(fs::metadata(&file_path).unwrap().modified().unwrap(), written_at);
3509 assert_eq!(fs::read(&file_path).unwrap(), b"same-bytes");
3510 }
3511
3512 #[test]
3513 fn the_images_directory_is_created_automatically_on_first_write() {
3514 let (_dir, mut table) = open_docs_table();
3515 assert!(!table.image_dir().exists());
3516
3517 table
3518 .put(
3519 &json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
3520 false,
3521 )
3522 .unwrap();
3523
3524 assert!(table.image_dir().is_dir());
3525 }
3526
3527 #[test]
3528 fn a_patch_that_does_not_touch_the_image_field_still_validates() {
3529 let (_dir, mut table) = open_docs_table();
3530 table
3531 .put(
3532 &json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
3533 false,
3534 )
3535 .unwrap();
3536
3537 let patched = table.patch("d1", &json!({}), false).unwrap();
3538 assert_eq!(
3539 patched.get("cover").unwrap().get("virtualPath"),
3540 Some(&json!("/images/a.png"))
3541 );
3542 }
3543
3544 fn pages_schema() -> TableSchema {
3545 parse_schema(&json!({
3546 "schemaVersion": 1,
3547 "table": "pages",
3548 "primaryKey": "id",
3549 "fields": {
3550 "id": { "type": "string" },
3551 "page": { "type": "markdown" },
3552 },
3553 }))
3554 .unwrap()
3555 }
3556
3557 fn open_pages_table() -> (tempfile::TempDir, Table) {
3558 let dir = tempdir().unwrap();
3559 let path = dir.path().join("pages.jsonl");
3560 let table = Table::open(pages_schema(), &path).unwrap();
3561 (dir, table)
3562 }
3563
3564 fn pages_with_cover_schema() -> TableSchema {
3565 parse_schema(&json!({
3566 "schemaVersion": 1,
3567 "table": "pages",
3568 "primaryKey": "id",
3569 "fields": {
3570 "id": { "type": "string" },
3571 "page": { "type": "markdown" },
3572 "cover": { "type": "image" },
3573 },
3574 }))
3575 .unwrap()
3576 }
3577
3578 fn open_pages_with_cover_table() -> (tempfile::TempDir, Table) {
3579 let dir = tempdir().unwrap();
3580 let path = dir.path().join("pages.jsonl");
3581 let table = Table::open(pages_with_cover_schema(), &path).unwrap();
3582 (dir, table)
3583 }
3584
3585 fn open_pages_with_notes_table() -> (tempfile::TempDir, Table) {
3588 let schema = parse_schema(&json!({
3589 "schemaVersion": 1,
3590 "table": "pages",
3591 "primaryKey": "id",
3592 "fields": {
3593 "id": { "type": "string" },
3594 "page": { "type": "markdown" },
3595 "notes": { "type": "markdown", "addressable": false },
3596 },
3597 }))
3598 .unwrap();
3599 let dir = tempdir().unwrap();
3600 let path = dir.path().join("pages.jsonl");
3601 let table = Table::open(schema, &path).unwrap();
3602 (dir, table)
3603 }
3604
3605 #[test]
3606 fn a_non_addressable_field_links_to_an_addressable_one_and_the_link_resolves() {
3607 let (_dir, mut table) = open_pages_with_notes_table();
3608
3609 let outcome = table
3610 .put_with_warnings(
3611 &json!({
3612 "id": "a",
3613 "page": { "content": "the target", "virtualPath": "/docs/a.md" },
3614 "notes": { "content": "see [a](/docs/a.md)" },
3615 }),
3616 false,
3617 )
3618 .unwrap();
3619
3620 assert!(
3621 outcome.warnings.is_empty(),
3622 "the content of a non-addressable field is still scanned, and this link resolves: {:?}",
3623 outcome.warnings
3624 );
3625 assert_eq!(
3626 table.broken_links_of("a").unwrap(),
3627 Vec::new(),
3628 "asking on demand has to agree"
3629 );
3630 }
3631
3632 #[test]
3633 fn a_broken_link_in_a_non_addressable_field_is_still_reported_against_that_field() {
3634 let (_dir, mut table) = open_pages_with_notes_table();
3635
3636 let outcome = table
3637 .put_with_warnings(
3638 &json!({
3639 "id": "a",
3640 "page": { "content": "", "virtualPath": "/docs/a.md" },
3641 "notes": { "content": "see [gone](/docs/missing.md)" },
3642 }),
3643 false,
3644 )
3645 .unwrap();
3646
3647 assert_eq!(
3648 outcome.warnings,
3649 vec![BrokenLink {
3650 field: "notes".to_string(),
3651 path: "/docs/missing.md".to_string()
3652 }]
3653 );
3654 }
3655
3656 #[test]
3657 fn nothing_can_link_to_a_non_addressable_field_because_it_holds_no_virtual_path() {
3658 let (_dir, mut table) = open_pages_with_notes_table();
3659 table
3660 .put(
3661 &json!({
3662 "id": "a",
3663 "page": { "content": "", "virtualPath": "/docs/a.md" },
3664 "notes": { "content": "unreachable", "virtualPath": "/notes/a.md" },
3665 }),
3666 false,
3667 )
3668 .unwrap();
3669
3670 let outcome = table
3671 .put_with_warnings(
3672 &json!({
3673 "id": "b",
3674 "page": { "content": "[notes](/notes/a.md)", "virtualPath": "/docs/b.md" },
3675 }),
3676 false,
3677 )
3678 .unwrap();
3679
3680 assert_eq!(
3681 table.registry.virtual_path_fields().cloned().collect::<Vec<String>>(),
3682 vec!["page".to_string()],
3683 "only the addressable field belongs in the virtual-path index"
3684 );
3685 assert_eq!(
3686 outcome.warnings,
3687 vec![BrokenLink {
3688 field: "page".to_string(),
3689 path: "/notes/a.md".to_string()
3690 }],
3691 "a path only a non-addressable field carries is not a link target"
3692 );
3693 }
3694
3695 #[test]
3696 fn two_records_may_share_the_path_of_a_non_addressable_field() {
3697 let (_dir, mut table) = open_pages_with_notes_table();
3698 let record = |id: &str| {
3699 json!({
3700 "id": id,
3701 "page": { "content": "", "virtualPath": format!("/docs/{id}.md") },
3702 "notes": { "content": "x", "virtualPath": "/notes/shared.md" },
3703 })
3704 };
3705
3706 table.put(&record("a"), false).unwrap();
3707 let second = table.put(&record("b"), false);
3708
3709 assert!(
3710 second.is_ok(),
3711 "an unindexed path enforces no uniqueness: {:?}",
3712 second.err()
3713 );
3714 }
3715
3716 #[test]
3717 fn a_non_addressable_field_needs_no_path_of_its_own_at_all() {
3718 let (_dir, mut table) = open_pages_with_notes_table();
3719
3720 table
3721 .put(
3722 &json!({ "id": "a", "page": { "content": "", "virtualPath": "/docs/a.md" }, "notes": { "content": "x" } }),
3723 false,
3724 )
3725 .unwrap();
3726
3727 let stored = table.get("a").unwrap().unwrap();
3728 assert_eq!(stored["notes"], json!({ "content": "x" }));
3729 }
3730
3731 #[test]
3732 fn a_table_opened_with_no_link_parsing_option_defaults_to_eager() {
3733 let (_dir, table) = open_pages_table();
3734 assert_eq!(table.link_parsing(), LinkParsing::Eager);
3735 }
3736
3737 #[test]
3738 fn eager_put_of_a_broken_link_still_succeeds_but_carries_a_warning() {
3739 let (_dir, mut table) = open_pages_table();
3740 let outcome = table
3741 .put_with_warnings(
3742 &json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
3743 false,
3744 )
3745 .unwrap();
3746
3747 assert!(table.get("a").unwrap().is_some());
3748 assert_eq!(
3749 outcome.warnings,
3750 vec![BrokenLink {
3751 field: "page".to_string(),
3752 path: "/docs/missing.md".to_string()
3753 }]
3754 );
3755 }
3756
3757 #[test]
3758 fn eager_put_of_a_link_to_an_existing_page_carries_no_warnings() {
3759 let (_dir, mut table) = open_pages_table();
3760 table
3761 .put_with_warnings(
3762 &json!({ "id": "a", "page": { "content": "", "virtualPath": "/docs/a.md" } }),
3763 false,
3764 )
3765 .unwrap();
3766
3767 let outcome = table
3768 .put_with_warnings(
3769 &json!({ "id": "b", "page": { "content": "[a](/docs/a.md)", "virtualPath": "/docs/b.md" } }),
3770 false,
3771 )
3772 .unwrap();
3773
3774 assert!(outcome.warnings.is_empty());
3775 }
3776
3777 #[test]
3778 fn eager_put_of_a_link_to_a_cover_images_virtual_path_in_the_same_record_carries_no_warnings() {
3779 let (_dir, mut table) = open_pages_with_cover_table();
3780 let outcome = table
3781 .put_with_warnings(
3782 &json!({
3783 "id": "a",
3784 "page": { "content": "[cover](/images/a.png)", "virtualPath": "/docs/a.md" },
3785 "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" },
3786 }),
3787 false,
3788 )
3789 .unwrap();
3790
3791 assert!(outcome.warnings.is_empty());
3792 }
3793
3794 #[test]
3795 fn eager_put_of_a_link_to_a_cover_images_virtual_path_in_another_record_carries_no_warnings() {
3796 let (_dir, mut table) = open_pages_with_cover_table();
3797 table
3798 .put_with_warnings(
3799 &json!({ "id": "a", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
3800 false,
3801 )
3802 .unwrap();
3803
3804 let outcome = table
3805 .put_with_warnings(
3806 &json!({ "id": "b", "page": { "content": "[cover](/images/a.png)", "virtualPath": "/docs/b.md" } }),
3807 false,
3808 )
3809 .unwrap();
3810
3811 assert!(outcome.warnings.is_empty());
3812 }
3813
3814 #[test]
3815 fn eager_put_of_a_link_to_a_path_no_field_holds_still_carries_a_warning() {
3816 let (_dir, mut table) = open_pages_with_cover_table();
3817 table
3818 .put_with_warnings(
3819 &json!({ "id": "a", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
3820 false,
3821 )
3822 .unwrap();
3823
3824 let outcome = table
3825 .put_with_warnings(
3826 &json!({ "id": "b", "page": { "content": "[nope](/images/missing.png)", "virtualPath": "/docs/b.md" } }),
3827 false,
3828 )
3829 .unwrap();
3830
3831 assert_eq!(
3832 outcome.warnings,
3833 vec![BrokenLink {
3834 field: "page".to_string(),
3835 path: "/images/missing.png".to_string()
3836 }]
3837 );
3838 }
3839
3840 #[test]
3841 fn lazy_link_parsing_reports_no_automatic_warning_but_broken_links_of_finds_it_on_request() {
3842 let (_dir, mut table) = open_pages_table();
3843 table.set_link_parsing(LinkParsing::Lazy);
3844
3845 let outcome = table
3846 .put_with_warnings(
3847 &json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
3848 false,
3849 )
3850 .unwrap();
3851 assert!(outcome.warnings.is_empty());
3852
3853 let broken = table.broken_links_of("a").unwrap();
3854 assert_eq!(
3855 broken,
3856 vec![BrokenLink {
3857 field: "page".to_string(),
3858 path: "/docs/missing.md".to_string()
3859 }]
3860 );
3861 }
3862
3863 #[test]
3864 fn asking_after_the_write_gives_an_eager_table_the_same_warnings_put_with_warnings_carried() {
3865 let (_dir, mut table) = open_pages_table();
3866 let outcome = table
3867 .put_with_warnings(
3868 &json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
3869 false,
3870 )
3871 .unwrap();
3872
3873 assert_eq!(table.eager_link_warnings_of("a").unwrap(), outcome.warnings);
3874 assert_eq!(
3875 table.eager_link_warnings_of("a").unwrap(),
3876 vec![BrokenLink {
3877 field: "page".to_string(),
3878 path: "/docs/missing.md".to_string()
3879 }]
3880 );
3881 }
3882
3883 #[test]
3884 fn asking_after_the_write_stays_silent_on_a_lazy_table_that_broken_links_of_would_answer_for() {
3885 let (_dir, mut table) = open_pages_table();
3886 table.set_link_parsing(LinkParsing::Lazy);
3887 table
3888 .put_with_warnings(
3889 &json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
3890 false,
3891 )
3892 .unwrap();
3893
3894 assert!(table.eager_link_warnings_of("a").unwrap().is_empty());
3895 assert_eq!(table.broken_links_of("a").unwrap().len(), 1);
3896 }
3897
3898 #[test]
3899 fn plain_put_and_patch_still_return_a_bare_record_alongside_the_warning_variants() {
3900 let (_dir, mut table) = open_pages_table();
3901 let record = table
3902 .put(
3903 &json!({ "id": "a", "page": { "content": "no links here", "virtualPath": "/docs/a.md" } }),
3904 false,
3905 )
3906 .unwrap();
3907 assert_eq!(record.get("id"), Some(&json!("a")));
3908
3909 let patched = table
3910 .patch(
3911 "a",
3912 &json!({ "page": { "content": "[missing](/docs/x.md)", "virtualPath": "/docs/a.md" } }),
3913 false,
3914 )
3915 .unwrap();
3916 assert_eq!(patched.get("id"), Some(&json!("a")));
3917 }
3918
3919 #[test]
3920 fn compact_drops_dead_versions_but_leaves_the_table_fully_usable() {
3921 let (_dir, mut table) = open_table();
3922 table
3923 .put(
3924 &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
3925 false,
3926 )
3927 .unwrap();
3928 table
3929 .put(
3930 &json!({ "id": "u2", "name": "Piotr", "age": 24, "email": "u2@x.com" }),
3931 false,
3932 )
3933 .unwrap();
3934 table
3935 .put(
3936 &json!({ "id": "u3", "name": "Kasia", "age": 40, "email": "u3@x.com" }),
3937 false,
3938 )
3939 .unwrap();
3940 table.patch("u2", &json!({ "age": 25 }), false).unwrap();
3941 table.delete("u3", false).unwrap();
3942
3943 let result = table.compact().unwrap();
3944 assert_eq!(result.records, 2);
3945 assert!(result.reclaimed_bytes > 0);
3946
3947 let u1 = table.get("u1").unwrap().unwrap();
3948 assert_eq!(u1.get("name"), Some(&json!("Ania")));
3949 let u2 = table.get("u2").unwrap().unwrap();
3950 assert_eq!(u2.get("age"), Some(&json!(25)));
3951 assert_eq!(table.get("u3").unwrap(), None);
3952
3953 table
3954 .put(
3955 &json!({ "id": "u4", "name": "Zosia", "age": 22, "email": "u4@x.com" }),
3956 false,
3957 )
3958 .unwrap();
3959 assert_eq!(table.get("u4").unwrap().unwrap().get("name"), Some(&json!("Zosia")));
3960 table.snapshot().unwrap();
3961 assert!(!table.needs_snapshot());
3962 }
3963
3964 mod half_written_tail {
3965 use std::io::Write as _;
3966
3967 use super::*;
3968
3969 const TORN: &[u8] = br#"{"_k":"u3","_op":"put","_seq":9,"_ts":1,"name":"Half"#;
3971
3972 fn seeded(path: &Path) -> u64 {
3973 let mut table = Table::open(users_schema(), path).unwrap();
3974 table
3975 .put(
3976 &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
3977 false,
3978 )
3979 .unwrap();
3980 table
3981 .put(
3982 &json!({ "id": "u2", "name": "Piotr", "age": 40, "email": "u2@x.com" }),
3983 false,
3984 )
3985 .unwrap();
3986 table.flush().unwrap();
3987 fs::metadata(path).unwrap().len()
3988 }
3989
3990 fn tear(path: &Path) {
3991 let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap();
3992 file.write_all(TORN).unwrap();
3993 }
3994
3995 fn tracked_size(table: &Table) -> u64 {
3996 table.logs.log_of(UNPARTITIONED).unwrap().size()
3997 }
3998
3999 #[test]
4000 fn a_writable_open_cuts_the_torn_tail_off_the_log_so_the_next_append_stays_readable() {
4001 let dir = tempdir().unwrap();
4002 let path = dir.path().join("users.jsonl");
4003 let healthy = seeded(&path);
4004 tear(&path);
4005 assert!(fs::metadata(&path).unwrap().len() > healthy);
4006
4007 let mut table = Table::open(users_schema(), &path).unwrap();
4008 assert_eq!(tracked_size(&table), healthy);
4009 assert_eq!(fs::metadata(&path).unwrap().len(), healthy);
4010
4011 table
4012 .put(
4013 &json!({ "id": "u3", "name": "Zosia", "age": 22, "email": "u3@x.com" }),
4014 false,
4015 )
4016 .unwrap();
4017 table.flush().unwrap();
4018
4019 let reopened = Table::open(users_schema(), &path).unwrap();
4021 let mut keys: Vec<&str> = reopened.keys().map(String::as_str).collect();
4022 keys.sort_unstable();
4023 assert_eq!(keys, ["u1", "u2", "u3"]);
4024 assert_eq!(reopened.get("u3").unwrap().unwrap().get("name"), Some(&json!("Zosia")));
4025
4026 let raw = fs::read_to_string(&path).unwrap();
4027 let lines: Vec<&str> = raw.trim().lines().collect();
4028 assert_eq!(lines.len(), 3);
4029 assert!(lines.iter().all(|line| serde_json::from_str::<Value>(line).is_ok()));
4030 }
4031
4032 #[test]
4033 fn a_readonly_open_moves_its_own_end_marker_and_leaves_the_torn_file_alone() {
4034 let dir = tempdir().unwrap();
4035 let path = dir.path().join("users.jsonl");
4036 let healthy = seeded(&path);
4037 tear(&path);
4038 let torn_size = fs::metadata(&path).unwrap().len();
4039
4040 let (table, outcome) = Table::open_with_snapshot_and_declared_indexes(
4041 users_schema(),
4042 &path,
4043 dir.path().join("users.idx"),
4044 "",
4045 0,
4046 true,
4047 &[],
4048 )
4049 .unwrap();
4050
4051 assert_eq!(outcome, RestoreOutcome::Rebuilt);
4052 assert_eq!(tracked_size(&table), healthy);
4053 assert_eq!(fs::metadata(&path).unwrap().len(), torn_size);
4054 let mut keys: Vec<&str> = table.keys().map(String::as_str).collect();
4055 keys.sort_unstable();
4056 assert_eq!(keys, ["u1", "u2"]);
4057 }
4058 }
4059
4060 mod cypher {
4061 use super::*;
4062 use crate::crypto::{CipherOptions, MissingKeyPolicy};
4063 use std::fs;
4064
4065 fn key(byte: u8) -> Vec<u8> {
4066 vec![byte; 32]
4067 }
4068
4069 fn secrets_schema() -> TableSchema {
4070 parse_schema(&json!({
4071 "schemaVersion": 1,
4072 "table": "secrets",
4073 "primaryKey": "id",
4074 "fields": {
4075 "id": { "type": "string" },
4076 "label": { "type": "string", "index": "exact" },
4077 "token": { "type": "string", "cypher": true },
4078 "pin": { "type": "int", "cypher": true },
4079 },
4080 }))
4081 .unwrap()
4082 }
4083
4084 fn cipher_with(keys: &[(&str, u8)], on_missing_key: Option<MissingKeyPolicy>) -> FieldCipher {
4085 FieldCipher::new(CipherOptions {
4086 keys: keys.iter().map(|(id, byte)| (id.to_string(), key(*byte))).collect(),
4087 key_id: None,
4088 on_missing_key,
4089 })
4090 .unwrap()
4091 }
4092
4093 fn open_secrets(dir: &std::path::Path, cipher: Option<FieldCipher>) -> Table {
4094 let mut table = Table::open(secrets_schema(), dir.join("secrets.jsonl")).unwrap();
4095 table.set_cipher(cipher);
4096 table
4097 }
4098
4099 #[test]
4100 fn never_writes_the_plaintext_into_the_log() {
4101 let dir = tempdir().unwrap();
4102 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4103 table
4104 .put(
4105 &json!({ "id": "s1", "label": "deploy", "token": "hunter2-super-secret", "pin": 4321 }),
4106 false,
4107 )
4108 .unwrap();
4109
4110 let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
4111 assert!(!log.contains("hunter2-super-secret"));
4112 assert!(!log.contains("4321"));
4113 assert!(log.contains("$cypher$v1$k1$"));
4114 assert!(log.contains("deploy"));
4116 }
4117
4118 #[test]
4119 fn hands_the_plaintext_back_on_read_with_its_type_intact() {
4120 let dir = tempdir().unwrap();
4121 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4122 table
4123 .put(&json!({ "id": "s1", "token": "hunter2", "pin": 4321 }), false)
4124 .unwrap();
4125
4126 let found = table.get("s1").unwrap().unwrap();
4127 assert_eq!(found.get("token"), Some(&json!("hunter2")));
4128 assert_eq!(found.get("pin"), Some(&json!(4321)));
4129 }
4130
4131 #[test]
4132 fn a_staged_value_reads_back_plain_and_still_lands_sealed() {
4133 let dir = tempdir().unwrap();
4134 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4135 let path = dir.path().join("secrets.jsonl");
4136 table
4137 .stage_put(&json!({ "id": "s1", "label": "deploy", "token": "top-secret" }), false)
4138 .unwrap();
4139
4140 assert_eq!(
4141 table.get("s1").unwrap().unwrap().get("token"),
4142 Some(&json!("top-secret"))
4143 );
4144 assert!(
4145 fs::read_to_string(&path).unwrap_or_default().is_empty(),
4146 "nothing is on disk yet"
4147 );
4148
4149 table.flush().unwrap();
4150
4151 let written = fs::read_to_string(&path).unwrap();
4152 assert!(!written.contains("top-secret"), "the plaintext never reached the log");
4153 assert!(written.contains("$cypher$v1$k1$"));
4154 assert_eq!(
4155 table.get("s1").unwrap().unwrap().get("token"),
4156 Some(&json!("top-secret"))
4157 );
4158 }
4159
4160 #[test]
4161 fn two_equal_values_get_two_different_envelopes() {
4162 let dir = tempdir().unwrap();
4163 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4164 table.put(&json!({ "id": "s1", "token": "same" }), false).unwrap();
4165 table.put(&json!({ "id": "s2", "token": "same" }), false).unwrap();
4166
4167 let lines: Vec<String> = fs::read_to_string(dir.path().join("secrets.jsonl"))
4168 .unwrap()
4169 .trim()
4170 .lines()
4171 .map(str::to_string)
4172 .collect();
4173 let envelopes: Vec<String> = lines
4174 .iter()
4175 .map(|line| {
4176 serde_json::from_str::<Value>(line)
4177 .unwrap()
4178 .get("token")
4179 .unwrap()
4180 .as_str()
4181 .unwrap()
4182 .to_string()
4183 })
4184 .collect();
4185 assert_ne!(envelopes[0], envelopes[1]);
4186 }
4187
4188 #[test]
4189 fn leaves_null_alone() {
4190 let dir = tempdir().unwrap();
4191 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4192 table.put(&json!({ "id": "s1", "token": Value::Null }), false).unwrap();
4193 let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
4194 assert!(log.contains("\"token\":null"));
4195 }
4196
4197 #[test]
4198 fn survives_a_patch_of_an_encrypted_field() {
4199 let dir = tempdir().unwrap();
4200 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4201 table
4202 .put(
4203 &json!({ "id": "s1", "label": "deploy", "token": "first", "pin": 1 }),
4204 false,
4205 )
4206 .unwrap();
4207 table.patch("s1", &json!({ "token": "second" }), false).unwrap();
4208
4209 assert_eq!(table.get("s1").unwrap().unwrap().get("token"), Some(&json!("second")));
4210 assert_eq!(table.get("s1").unwrap().unwrap().get("pin"), Some(&json!(1)));
4211 let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
4212 assert!(!log.contains("\"second\""));
4213 }
4214
4215 #[test]
4216 fn survives_compaction_without_a_key() {
4217 let dir = tempdir().unwrap();
4218 {
4219 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4220 table.put(&json!({ "id": "s1", "token": "keep-me" }), false).unwrap();
4221 table
4222 .put(&json!({ "id": "s1", "token": "keep-me-instead" }), false)
4223 .unwrap();
4224 }
4225
4226 {
4227 let mut blind = open_secrets(dir.path(), None);
4228 blind.compact().unwrap();
4229 }
4230
4231 let reopened = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4232 assert_eq!(
4233 reopened.get("s1").unwrap().unwrap().get("token"),
4234 Some(&json!("keep-me-instead"))
4235 );
4236 }
4237
4238 #[test]
4239 fn matches_a_predicate_on_an_encrypted_field_by_scanning() {
4240 let dir = tempdir().unwrap();
4241 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4242 table.put(&json!({ "id": "s1", "token": "alpha" }), false).unwrap();
4243 table.put(&json!({ "id": "s2", "token": "beta" }), false).unwrap();
4244
4245 let spec = QueryBuilder::new()
4246 .r#where("token", ComparisonOp::Eq, json!("beta"))
4247 .spec();
4248 let found = table.query(&spec).unwrap();
4249 assert_eq!(found.keys, vec!["s2".to_string()]);
4250 }
4251
4252 #[test]
4253 fn refuses_to_write_an_encrypted_field_with_no_key() {
4254 let dir = tempdir().unwrap();
4255 let mut blind = open_secrets(dir.path(), None);
4256 let error = blind.put(&json!({ "id": "s9", "token": "nope" }), false).unwrap_err();
4257 assert!(matches!(error, TableError::Cipher(_)));
4258 }
4259
4260 #[test]
4261 fn refuses_to_read_without_the_key_that_wrote_the_value() {
4262 let dir = tempdir().unwrap();
4263 {
4264 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4265 table.put(&json!({ "id": "s1", "token": "secret" }), false).unwrap();
4266 }
4267
4268 let wrong = open_secrets(dir.path(), Some(cipher_with(&[("other", 2)], None)));
4269 let error = wrong.get("s1").unwrap_err();
4270 let TableError::Cipher(cipher) = &error else {
4273 panic!("a failed decrypt is a cipher refusal, not an io failure: {error:?}");
4274 };
4275 assert_eq!(cipher.table, "secrets");
4276 assert_eq!(cipher.field, "token");
4277 assert_eq!(cipher.key_id.as_deref(), Some("k1"));
4278 assert!(error.to_string().contains("was not given"));
4279 }
4280
4281 #[test]
4282 fn refuses_to_read_with_no_key_at_all() {
4283 let dir = tempdir().unwrap();
4284 {
4285 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4286 table.put(&json!({ "id": "s1", "token": "secret" }), false).unwrap();
4287 }
4288
4289 let blind = open_secrets(dir.path(), None);
4290 let error = blind.get("s1").unwrap_err();
4291 assert!(error.to_string().contains("cannot be read without a key"));
4292 }
4293
4294 #[test]
4295 fn omits_the_field_when_the_policy_says_so() {
4296 let dir = tempdir().unwrap();
4297 {
4298 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4299 table
4300 .put(&json!({ "id": "s1", "label": "deploy", "token": "secret" }), false)
4301 .unwrap();
4302 }
4303
4304 let partial = open_secrets(
4305 dir.path(),
4306 Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Omit))),
4307 );
4308 let found = partial.get("s1").unwrap().unwrap();
4309 assert_eq!(found.get("label"), Some(&json!("deploy")));
4310 assert_eq!(found.get("token"), None);
4311 }
4312
4313 #[test]
4314 fn hands_back_the_envelope_when_the_policy_says_raw() {
4315 let dir = tempdir().unwrap();
4316 {
4317 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4318 table.put(&json!({ "id": "s1", "token": "secret" }), false).unwrap();
4319 }
4320
4321 let raw = open_secrets(
4322 dir.path(),
4323 Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Raw))),
4324 );
4325 let found = raw.get("s1").unwrap().unwrap();
4326 assert!(found.get("token").unwrap().as_str().unwrap().contains("$cypher$v1$k1$"));
4327 }
4328
4329 const MASQUERADE: &str = "$cypher$v1$k1$not$real";
4330
4331 fn token_in(path: &std::path::Path, key: &str) -> String {
4332 fs::read_to_string(path)
4333 .unwrap()
4334 .lines()
4335 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
4336 .rfind(|entry| entry["_k"] == json!(key))
4337 .unwrap()["token"]
4338 .as_str()
4339 .unwrap()
4340 .to_string()
4341 }
4342
4343 #[test]
4344 fn a_value_that_only_looks_like_an_envelope_is_sealed_like_any_other() {
4345 let dir = tempdir().unwrap();
4346 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4347
4348 table
4349 .put(&json!({ "id": "s1", "label": "deploy", "token": MASQUERADE }), false)
4350 .unwrap();
4351
4352 let path = dir.path().join("secrets.jsonl");
4353 let stored = token_in(&path, "s1");
4354 assert_ne!(stored, MASQUERADE, "the value reached the log in the clear");
4355 assert!(!fs::read_to_string(&path).unwrap().contains("$not$real"));
4356 assert_eq!(
4357 table.get("s1").unwrap().unwrap().get("token"),
4358 Some(&json!(MASQUERADE)),
4359 "and it still reads back as what was written"
4360 );
4361 }
4362
4363 #[test]
4364 fn a_copy_between_databases_carries_the_envelope_byte_for_byte() {
4365 let source = tempdir().unwrap();
4366 {
4367 let mut table = open_secrets(source.path(), Some(cipher_with(&[("k1", 1)], None)));
4368 table
4369 .put(&json!({ "id": "s1", "label": "deploy", "token": "hunter2" }), false)
4370 .unwrap();
4371 }
4372 let envelope = {
4373 let raw = open_secrets(
4374 source.path(),
4375 Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Raw))),
4376 );
4377 raw.get("s1").unwrap().unwrap()["token"].as_str().unwrap().to_string()
4378 };
4379
4380 let target = tempdir().unwrap();
4382 {
4383 let mut table = open_secrets(target.path(), None);
4384 table
4385 .put_sealed(
4386 &json!({ "id": "s1", "label": "deploy", "token": envelope }),
4387 &["token".to_string()],
4388 false,
4389 )
4390 .unwrap();
4391 }
4392
4393 assert_eq!(
4394 token_in(&target.path().join("secrets.jsonl"), "s1"),
4395 envelope,
4396 "the copy re-sealed a value it was told was already sealed"
4397 );
4398 let restored = open_secrets(target.path(), Some(cipher_with(&[("k1", 1)], None)));
4399 assert_eq!(
4400 restored.get("s1").unwrap().unwrap().get("token"),
4401 Some(&json!("hunter2"))
4402 );
4403 }
4404
4405 #[test]
4406 fn a_carried_field_is_refused_when_it_is_not_an_envelope() {
4407 let dir = tempdir().unwrap();
4408 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4409
4410 let error = table
4411 .put_sealed(
4412 &json!({ "id": "s1", "token": MASQUERADE }),
4413 &["token".to_string()],
4414 false,
4415 )
4416 .unwrap_err();
4417
4418 assert!(error.to_string().contains("is not a cypher envelope"), "{error}");
4419 assert!(table.is_empty(), "the refused write still landed");
4420 }
4421
4422 #[test]
4423 fn a_carried_field_is_refused_when_the_envelope_belongs_to_another_record() {
4424 let dir = tempdir().unwrap();
4425 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4426 table.put(&json!({ "id": "s2", "token": "yours" }), false).unwrap();
4427 let stolen = {
4428 let raw = open_secrets(
4429 dir.path(),
4430 Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Raw))),
4431 );
4432 raw.get("s2").unwrap().unwrap()["token"].as_str().unwrap().to_string()
4433 };
4434
4435 let error = table
4436 .put_sealed(&json!({ "id": "s1", "token": stolen }), &["token".to_string()], false)
4437 .unwrap_err();
4438
4439 assert!(
4440 error.to_string().contains("does not open for this record and field"),
4441 "{error}"
4442 );
4443 }
4444
4445 #[test]
4446 fn a_field_that_is_not_declared_cypher_cannot_be_carried() {
4447 let dir = tempdir().unwrap();
4448 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4449
4450 let error = table
4451 .put_sealed(&json!({ "id": "s1", "label": "deploy" }), &["label".to_string()], false)
4452 .unwrap_err();
4453
4454 assert!(error.to_string().contains("not declared \"cypher\""), "{error}");
4455 }
4456
4457 #[test]
4458 fn a_carried_field_does_not_spare_another_write_in_the_same_window() {
4459 let source = tempdir().unwrap();
4460 {
4461 let mut table = open_secrets(source.path(), Some(cipher_with(&[("k1", 1)], None)));
4462 table.put(&json!({ "id": "s1", "token": "hunter2" }), false).unwrap();
4463 }
4464 let envelope = token_in(&source.path().join("secrets.jsonl"), "s1");
4465
4466 let target = tempdir().unwrap();
4467 let mut table = open_secrets(target.path(), Some(cipher_with(&[("k1", 1)], None)));
4468 table
4469 .stage_put(&json!({ "id": "s2", "token": MASQUERADE }), false)
4470 .unwrap();
4471 table
4472 .put_sealed(
4473 &json!({ "id": "s1", "token": envelope.clone() }),
4474 &["token".to_string()],
4475 false,
4476 )
4477 .unwrap();
4478
4479 let path = target.path().join("secrets.jsonl");
4480 assert_eq!(token_in(&path, "s1"), envelope);
4481 assert_ne!(
4482 token_in(&path, "s2"),
4483 MASQUERADE,
4484 "the import's claim spared a record it was never about"
4485 );
4486 }
4487
4488 #[test]
4489 fn refuses_a_value_moved_to_another_record() {
4490 let dir = tempdir().unwrap();
4491 {
4492 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4493 table.put(&json!({ "id": "s1", "token": "mine" }), false).unwrap();
4494 table.put(&json!({ "id": "s2", "token": "yours" }), false).unwrap();
4495 }
4496
4497 let path = dir.path().join("secrets.jsonl");
4498 let lines: Vec<String> = fs::read_to_string(&path)
4499 .unwrap()
4500 .trim()
4501 .lines()
4502 .map(str::to_string)
4503 .collect();
4504 let mut first: serde_json::Map<String, Value> = serde_json::from_str(&lines[0]).unwrap();
4505 let mut second: serde_json::Map<String, Value> = serde_json::from_str(&lines[1]).unwrap();
4506 let stolen = first.remove("token").unwrap();
4507 second.insert("token".to_string(), stolen);
4508 fs::write(
4509 &path,
4510 format!(
4511 "{}\n{}\n",
4512 serde_json::to_string(&first).unwrap(),
4513 serde_json::to_string(&second).unwrap()
4514 ),
4515 )
4516 .unwrap();
4517
4518 let reopened = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4519 let error = reopened.get("s2").unwrap_err();
4520 assert!(error.to_string().contains("authentication tag"));
4521 }
4522
4523 fn cipher_writing(
4526 keys: &[(&str, u8)],
4527 write_id: &str,
4528 on_missing_key: Option<MissingKeyPolicy>,
4529 ) -> FieldCipher {
4530 FieldCipher::new(CipherOptions {
4531 keys: keys.iter().map(|(id, byte)| ((*id).to_string(), key(*byte))).collect(),
4532 key_id: Some(write_id.to_string()),
4533 on_missing_key,
4534 })
4535 .unwrap()
4536 }
4537
4538 fn seeded_across_two_keys(dir: &std::path::Path) -> Table {
4541 let mut old = open_secrets(dir, Some(cipher_with(&[("k1", 1)], None)));
4542 old.put(
4543 &json!({ "id": "s1", "label": "old", "token": "first", "pin": 1111 }),
4544 false,
4545 )
4546 .unwrap();
4547 drop(old);
4548
4549 let mut table = open_secrets(dir, Some(cipher_writing(&[("k1", 1), ("k2", 2)], "k2", None)));
4550 table
4551 .put(
4552 &json!({ "id": "s2", "label": "new", "token": "second", "pin": 2222 }),
4553 false,
4554 )
4555 .unwrap();
4556 table
4557 }
4558
4559 fn envelope_key_ids_in(path: &std::path::Path) -> Vec<String> {
4560 fs::read_to_string(path)
4561 .unwrap()
4562 .lines()
4563 .filter(|line| !line.is_empty())
4564 .map(|line| {
4565 let record: serde_json::Map<String, Value> = serde_json::from_str(line).unwrap();
4566 crate::crypto::envelope_key_id(&record["token"]).unwrap().to_string()
4567 })
4568 .collect()
4569 }
4570
4571 #[test]
4572 fn rotation_re_encrypts_only_the_record_still_on_the_retired_key() {
4573 let dir = tempdir().unwrap();
4574 let mut table = seeded_across_two_keys(dir.path());
4575
4576 let result = table.rotate_cypher_keys().unwrap();
4577
4578 assert_eq!(
4579 (result.rotated, result.already_current, result.skipped),
4580 (1, 1, 0),
4581 "only \"s1\" was on k1"
4582 );
4583 let written = envelope_key_ids_in(&dir.path().join("secrets.jsonl"));
4584 assert_eq!(
4585 written,
4586 vec!["k1".to_string(), "k2".to_string(), "k2".to_string()],
4587 "the k1 line stays in the append-only log; the rotation appends the k2 version after it"
4588 );
4589 }
4590
4591 #[test]
4592 fn a_second_rotation_reports_nothing_left_to_do() {
4593 let dir = tempdir().unwrap();
4594 let mut table = seeded_across_two_keys(dir.path());
4595 table.rotate_cypher_keys().unwrap();
4596
4597 let again = table.rotate_cypher_keys().unwrap();
4598
4599 assert_eq!(
4600 (again.rotated, again.skipped),
4601 (0, 0),
4602 "a rotation nothing needs must rewrite nothing"
4603 );
4604 assert_eq!(again.already_current, 2);
4605 }
4606
4607 #[test]
4608 fn every_value_still_reads_back_as_itself_after_a_rotation() {
4609 let dir = tempdir().unwrap();
4610 let mut table = seeded_across_two_keys(dir.path());
4611
4612 table.rotate_cypher_keys().unwrap();
4613
4614 for (key, token, pin, label) in [("s1", "first", 1111, "old"), ("s2", "second", 2222, "new")] {
4615 let found = table.get(key).unwrap().unwrap();
4616 assert_eq!(found.get("token"), Some(&json!(token)), "{key}");
4617 assert_eq!(found.get("pin"), Some(&json!(pin)), "{key}");
4618 assert_eq!(found.get("label"), Some(&json!(label)), "{key}");
4619 }
4620 }
4621
4622 #[test]
4623 fn a_rotated_record_reads_back_with_the_new_key_alone() {
4624 let dir = tempdir().unwrap();
4625 let mut table = seeded_across_two_keys(dir.path());
4626 table.rotate_cypher_keys().unwrap();
4627 drop(table);
4628
4629 let without_the_old_key = open_secrets(dir.path(), Some(cipher_with(&[("k2", 2)], None)));
4630
4631 assert_eq!(
4632 without_the_old_key.get("s1").unwrap().unwrap().get("token"),
4633 Some(&json!("first")),
4634 "the whole point of rotating: k1 can now be retired"
4635 );
4636 }
4637
4638 #[test]
4639 fn a_table_with_no_cypher_field_answers_zero_without_reading_anything() {
4640 let dir = tempdir().unwrap();
4641 let mut table = Table::open(users_schema(), dir.path().join("users.jsonl")).unwrap();
4642 table
4643 .put(
4644 &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
4645 false,
4646 )
4647 .unwrap();
4648
4649 let result = table.rotate_cypher_keys().unwrap();
4650
4651 assert_eq!(result, RotationResult::default());
4652 }
4653
4654 #[test]
4655 fn a_missing_key_refuses_the_run_under_the_default_policy() {
4656 let dir = tempdir().unwrap();
4657 drop(seeded_across_two_keys(dir.path()));
4658 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k2", 2)], None)));
4659
4660 let error = table.rotate_cypher_keys().unwrap_err();
4661
4662 assert!(
4663 error.to_string().contains("s1"),
4664 "the refusal has to name the record: {error}"
4665 );
4666 assert!(error.to_string().contains("k1"), "and the key it wanted: {error}");
4667 }
4668
4669 #[test]
4670 fn a_missing_key_is_skipped_and_counted_when_the_policy_says_so() {
4671 let dir = tempdir().unwrap();
4672 drop(seeded_across_two_keys(dir.path()));
4673 let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k2", 2)], Some(MissingKeyPolicy::Raw))));
4674
4675 let result = table.rotate_cypher_keys().unwrap();
4676
4677 assert_eq!(
4678 (result.rotated, result.already_current, result.skipped),
4679 (0, 1, 1),
4680 "the unreadable record is left alone rather than re-sealed from its own envelope"
4681 );
4682 let written = envelope_key_ids_in(&dir.path().join("secrets.jsonl"));
4683 assert_eq!(
4684 written,
4685 vec!["k1".to_string(), "k2".to_string()],
4686 "nothing was appended"
4687 );
4688 }
4689
4690 #[test]
4691 fn rotation_reaches_every_partition_of_a_partitioned_table() {
4692 let schema = parse_schema(&json!({
4693 "schemaVersion": 1,
4694 "table": "secrets",
4695 "primaryKey": "id",
4696 "partitionBy": "tenant",
4697 "fields": {
4698 "id": { "type": "string" },
4699 "tenant": { "type": "string", "required": true },
4700 "token": { "type": "string", "cypher": true },
4701 },
4702 }))
4703 .unwrap();
4704 let dir = tempdir().unwrap();
4705 let path = dir.path().join("secrets.jsonl");
4706 let open = |cipher| {
4707 let mut table = Table::open(schema.clone(), &path).unwrap();
4708 table.set_cipher(Some(cipher));
4709 table
4710 };
4711 let mut old = open(cipher_with(&[("k1", 1)], None));
4712 old.put(&json!({ "id": "s1", "tenant": "acme", "token": "a" }), false)
4713 .unwrap();
4714 old.put(&json!({ "id": "s2", "tenant": "globex", "token": "b" }), false)
4715 .unwrap();
4716 drop(old);
4717 let mut table = open(cipher_writing(&[("k1", 1), ("k2", 2)], "k2", None));
4718
4719 let result = table.rotate_cypher_keys().unwrap();
4720
4721 assert_eq!((result.rotated, result.already_current), (2, 0));
4722 for (key, tenant, token) in [("s1", "acme", "a"), ("s2", "globex", "b")] {
4723 let found = table.get(key).unwrap().unwrap();
4724 assert_eq!(found.get("token"), Some(&json!(token)));
4725 assert_eq!(
4726 found.get("tenant"),
4727 Some(&json!(tenant)),
4728 "the record must not change partition"
4729 );
4730 }
4731 for tenant in ["acme", "globex"] {
4732 let log = fs::read_to_string(dir.path().join("secrets").join(format!("{tenant}.jsonl"))).unwrap();
4733 assert!(log.contains("$cypher$v1$k2$"), "{tenant} was rotated in its own file");
4734 }
4735 }
4736
4737 #[test]
4738 fn rotation_is_refused_on_a_readonly_handle() {
4739 let dir = tempdir().unwrap();
4740 drop(seeded_across_two_keys(dir.path()));
4741 let mut table = Table::open_with_snapshot_and_declared_indexes(
4742 secrets_schema(),
4743 dir.path().join("secrets.jsonl"),
4744 dir.path().join("secrets.idx"),
4745 "",
4746 0,
4747 true,
4748 &[],
4749 )
4750 .unwrap()
4751 .0;
4752 table.set_cipher(Some(cipher_writing(&[("k1", 1), ("k2", 2)], "k2", None)));
4753
4754 let error = table.rotate_cypher_keys().unwrap_err();
4755
4756 assert!(error.to_string().contains("readonly"), "{error}");
4757 }
4758 }
4759
4760 mod cypher_txn {
4761 use super::*;
4762 use crate::crypto::CipherOptions;
4763 use std::collections::HashMap;
4764 use std::fs;
4765
4766 fn key(byte: u8) -> Vec<u8> {
4767 vec![byte; 32]
4768 }
4769
4770 fn secrets_schema() -> TableSchema {
4771 parse_schema(&json!({
4772 "schemaVersion": 1,
4773 "table": "secrets",
4774 "primaryKey": "id",
4775 "fields": {
4776 "id": { "type": "string" },
4777 "token": { "type": "string", "cypher": true },
4778 },
4779 }))
4780 .unwrap()
4781 }
4782
4783 #[test]
4784 fn prepare_txn_ops_seals_the_line_but_keeps_the_prepared_record_plain() {
4785 let dir = tempdir().unwrap();
4786 let cipher = FieldCipher::new(CipherOptions {
4787 keys: HashMap::from([("k1".to_string(), key(1))]),
4788 key_id: None,
4789 on_missing_key: None,
4790 })
4791 .unwrap();
4792 let mut table = Table::open(secrets_schema(), dir.path().join("secrets.jsonl")).unwrap();
4793 table.set_cipher(Some(cipher));
4794
4795 let prepared = table
4796 .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "s1", "token": "top-secret" }))])
4797 .unwrap();
4798 assert_eq!(prepared.len(), 1);
4799 assert!(prepared[0].line.contains("$cypher$v1$k1$"));
4800 assert!(!prepared[0].line.contains("top-secret"));
4801 assert_eq!(
4802 prepared[0].record.as_ref().unwrap().get("token"),
4803 Some(&json!("top-secret"))
4804 );
4805
4806 table.apply_txn_entries(prepared).unwrap();
4807 let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
4808 assert!(!log.contains("top-secret"));
4809 assert_eq!(
4810 table.get("s1").unwrap().unwrap().get("token"),
4811 Some(&json!("top-secret"))
4812 );
4813 }
4814 }
4815
4816 mod partitions {
4819 use std::fs;
4820
4821 use super::*;
4822 use crate::query::predicates::ComparisonOp;
4823 use crate::registry::fulltext_index::SearchMode;
4824
4825 fn notes_schema(partitioned: bool) -> TableSchema {
4826 let mut spec = json!({
4827 "schemaVersion": 1,
4828 "table": "notes",
4829 "primaryKey": "id",
4830 "fields": {
4831 "id": { "type": "string" },
4832 "kind": { "type": "string", "required": true, "index": "exact" },
4833 "title": { "type": "string", "required": true, "fulltext": true },
4834 "body": { "type": "string", "fulltext": true },
4835 "slug": { "type": "string", "unique": true },
4836 },
4837 });
4838 if partitioned {
4839 spec.as_object_mut()
4840 .unwrap()
4841 .insert("partitionBy".into(), json!("kind"));
4842 }
4843 parse_schema(&spec).unwrap()
4844 }
4845
4846 fn seed() -> Vec<Value> {
4847 vec![
4848 json!({ "id": "n1", "kind": "note", "title": "Backend developer", "body": "silnik zapytań", "slug": "n1" }),
4849 json!({ "id": "n2", "kind": "note", "title": "Frontend notes", "body": "react", "slug": "n2" }),
4850 json!({ "id": "a1", "kind": "adr", "title": "Backend decision", "body": "silnik składowania", "slug": "a1" }),
4851 json!({ "id": "r1", "kind": "rule", "title": "Naming rule", "body": "kebab case", "slug": "r1" }),
4852 ]
4853 }
4854
4855 fn log_path(dir: &std::path::Path) -> std::path::PathBuf {
4856 dir.join("notes.jsonl")
4857 }
4858
4859 fn open_notes(dir: &std::path::Path) -> Table {
4860 Table::open(notes_schema(true), log_path(dir)).unwrap()
4861 }
4862
4863 fn partition_lines(dir: &std::path::Path, part: &str) -> Vec<Value> {
4864 fs::read_to_string(dir.join("notes").join(format!("{part}.jsonl")))
4865 .unwrap()
4866 .trim()
4867 .lines()
4868 .map(|line| serde_json::from_str(line).unwrap())
4869 .collect()
4870 }
4871
4872 #[test]
4873 fn keeps_one_log_file_per_partition_value() {
4874 let dir = tempdir().unwrap();
4875 let mut notes = open_notes(dir.path());
4876 for note in seed() {
4877 notes.put(¬e, false).unwrap();
4878 }
4879
4880 assert!(!log_path(dir.path()).exists());
4881 assert_eq!(partition_lines(dir.path(), "note").len(), 2);
4882 assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
4883 assert_eq!(partition_lines(dir.path(), "rule").len(), 1);
4884 for line in partition_lines(dir.path(), "adr") {
4885 assert_eq!(line.get("kind"), Some(&json!("adr")));
4886 }
4887 }
4888
4889 #[test]
4894 fn history_of_a_key_that_moved_partitions_reads_as_one_list_across_both_files() {
4895 let dir = tempdir().unwrap();
4896 let mut notes = open_notes(dir.path());
4897 notes
4898 .put(
4899 &json!({ "id": "n1", "kind": "note", "title": "First", "slug": "n1" }),
4900 false,
4901 )
4902 .unwrap();
4903 notes.patch("n1", &json!({ "title": "Second" }), false).unwrap();
4904 let moved = notes.put(
4905 &json!({ "id": "n1", "kind": "adr", "title": "Third", "slug": "n1" }),
4906 false,
4907 );
4908 assert!(moved.is_err(), "a live key may not change partition");
4909 notes.delete("n1", false).unwrap();
4910 notes
4911 .put(
4912 &json!({ "id": "n1", "kind": "adr", "title": "Third", "slug": "n1" }),
4913 false,
4914 )
4915 .unwrap();
4916
4917 let history = notes.history("n1").unwrap();
4918
4919 assert_eq!(
4920 history.scanned,
4921 vec![
4922 dir.path().join("notes").join("adr.jsonl"),
4923 dir.path().join("notes").join("note.jsonl"),
4924 ]
4925 );
4926 let shape: Vec<(u64, LogOp, &str, Option<&Value>)> = history
4927 .entries
4928 .iter()
4929 .map(|entry| {
4930 (
4931 entry.seq,
4932 entry.op,
4933 entry.partition.as_str(),
4934 entry.document.as_ref().and_then(|d| d.get("title")),
4935 )
4936 })
4937 .collect();
4938 assert_eq!(
4939 shape,
4940 vec![
4941 (1, LogOp::Put, "note", Some(&json!("First"))),
4942 (2, LogOp::Put, "note", Some(&json!("Second"))),
4943 (3, LogOp::Del, "note", None),
4944 (4, LogOp::Put, "adr", Some(&json!("Third"))),
4945 ]
4946 );
4947 assert!(notes.history("n2").unwrap().entries.is_empty());
4948 }
4949
4950 #[test]
4954 fn history_of_a_declared_but_unsplit_table_reads_the_flat_log() {
4955 let dir = tempdir().unwrap();
4956 {
4957 let mut flat = Table::open(notes_schema(false), log_path(dir.path())).unwrap();
4958 flat.put(
4959 &json!({ "id": "n1", "kind": "note", "title": "First", "slug": "n1" }),
4960 false,
4961 )
4962 .unwrap();
4963 flat.patch("n1", &json!({ "title": "Second" }), false).unwrap();
4964 }
4965
4966 let (notes, _) = Table::open_with_snapshot_and_declared_indexes(
4967 notes_schema(true),
4968 log_path(dir.path()),
4969 dir.path().join("notes.idx"),
4970 "hash",
4971 0,
4972 true,
4973 &[],
4974 )
4975 .unwrap();
4976
4977 let history = notes.history("n1").unwrap();
4978
4979 assert_eq!(history.scanned, vec![log_path(dir.path())]);
4980 assert_eq!(
4981 history
4982 .entries
4983 .iter()
4984 .map(|entry| (entry.seq, entry.partition.clone()))
4985 .collect::<Vec<_>>(),
4986 vec![(1, UNPARTITIONED.to_string()), (2, UNPARTITIONED.to_string())]
4987 );
4988 }
4989
4990 #[test]
4991 fn reads_every_partition_back_after_a_restart() {
4992 let dir = tempdir().unwrap();
4993 {
4994 let mut notes = open_notes(dir.path());
4995 for note in seed() {
4996 notes.put(¬e, false).unwrap();
4997 }
4998 }
4999
5000 let notes = open_notes(dir.path());
5001 assert_eq!(notes.registry.records(), 4);
5002 assert_eq!(
5003 notes.get("a1").unwrap().unwrap().get("title"),
5004 Some(&json!("Backend decision"))
5005 );
5006 assert_eq!(notes.get("r1").unwrap().unwrap().get("kind"), Some(&json!("rule")));
5007
5008 let spec = QueryBuilder::new()
5009 .r#where("kind", ComparisonOp::Eq, json!("note"))
5010 .spec();
5011 let mut found = notes.query(&spec).unwrap().keys;
5012 found.sort();
5013 assert_eq!(found, vec!["n1".to_string(), "n2".to_string()]);
5014 }
5015
5016 #[test]
5017 fn pairs_the_index_snapshot_with_every_partition_it_covers() {
5018 let dir = tempdir().unwrap();
5019 let snapshot_path = dir.path().join("notes.idx");
5020 let (mut notes, _) =
5021 Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5022 for note in seed() {
5023 notes.put(¬e, false).unwrap();
5024 }
5025 notes.snapshot().unwrap();
5026
5027 let snapshot = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
5028 let parts = snapshot.header.parts.unwrap();
5029 assert_eq!(
5030 parts.iter().map(|part| part.part.clone()).collect::<Vec<_>>(),
5031 vec!["adr".to_string(), "note".to_string(), "rule".to_string()]
5032 );
5033 for part in &parts {
5034 let on_disk = fs::metadata(dir.path().join("notes").join(format!("{}.jsonl", part.part))).unwrap();
5035 assert_eq!(part.watermark, on_disk.len());
5036 assert!(!part.log_hash.is_empty());
5037 }
5038 assert_eq!(snapshot.header.log_hash, None);
5039 }
5040
5041 #[test]
5042 fn compaction_repairs_every_partition_watermark_in_the_snapshot() {
5043 let dir = tempdir().unwrap();
5044 let snapshot_path = dir.path().join("notes.idx");
5045 let (mut notes, _) =
5046 Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5047 for note in seed() {
5048 notes.put(¬e, false).unwrap();
5049 }
5050 notes
5051 .put(&json!({ "id": "n1", "kind": "note", "title": "Rewritten" }), false)
5052 .unwrap();
5053 notes.delete("r1", false).unwrap();
5054 notes.snapshot().unwrap();
5055
5056 notes.compact().unwrap();
5057
5058 let snapshot = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
5059 for part in snapshot.header.parts.unwrap() {
5060 let on_disk = fs::metadata(dir.path().join("notes").join(format!("{}.jsonl", part.part))).unwrap();
5061 assert_eq!(part.watermark, on_disk.len(), "partition \"{}\" watermark", part.part);
5062 assert_eq!(part.dead_bytes, 0, "partition \"{}\" dead bytes", part.part);
5063 }
5064 assert_eq!(snapshot.header.watermark, notes.logs.total_size());
5065
5066 let (reopened, outcome) =
5067 Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5068 assert_eq!(outcome, RestoreOutcome::Restored);
5069 assert_eq!(reopened.registry.records(), 3);
5070 assert_eq!(
5071 reopened.get("n1").unwrap().unwrap().get("title"),
5072 Some(&json!("Rewritten"))
5073 );
5074 assert_eq!(reopened.get("r1").unwrap(), None);
5075 }
5076
5077 #[test]
5078 fn restores_the_registry_from_a_snapshot_that_covers_every_partition() {
5079 let dir = tempdir().unwrap();
5080 let snapshot_path = dir.path().join("notes.idx");
5081 {
5082 let (mut notes, _) =
5083 Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0)
5084 .unwrap();
5085 for note in seed() {
5086 notes.put(¬e, false).unwrap();
5087 }
5088 notes.snapshot().unwrap();
5089 }
5090
5091 let (notes, outcome) =
5092 Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5093 assert_eq!(outcome, RestoreOutcome::Restored);
5094 assert_eq!(notes.registry.records(), 4);
5095 assert_eq!(
5096 notes.get("a1").unwrap().unwrap().get("title"),
5097 Some(&json!("Backend decision"))
5098 );
5099 }
5100
5101 #[test]
5102 fn rebuilds_when_one_partition_grew_after_the_snapshot_was_taken() {
5103 let dir = tempdir().unwrap();
5104 let snapshot_path = dir.path().join("notes.idx");
5105 {
5106 let (mut notes, _) =
5107 Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0)
5108 .unwrap();
5109 for note in seed() {
5110 notes.put(¬e, false).unwrap();
5111 }
5112 notes.snapshot().unwrap();
5113 notes
5114 .put(
5115 &json!({ "id": "n3", "kind": "note", "title": "after the snapshot" }),
5116 false,
5117 )
5118 .unwrap();
5119 }
5120
5121 let (notes, outcome) =
5122 Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5123 assert_eq!(outcome, RestoreOutcome::Rebuilt);
5124 assert_eq!(notes.registry.records(), 5);
5125 assert_eq!(
5126 notes.get("n3").unwrap().unwrap().get("title"),
5127 Some(&json!("after the snapshot"))
5128 );
5129 }
5130
5131 #[test]
5132 fn holds_uniqueness_across_partitions_not_just_inside_one() {
5133 let dir = tempdir().unwrap();
5134 let mut notes = open_notes(dir.path());
5135 notes
5136 .put(
5137 &json!({ "id": "n1", "kind": "note", "title": "first", "slug": "shared" }),
5138 false,
5139 )
5140 .unwrap();
5141
5142 let error = notes
5143 .put(
5144 &json!({ "id": "a1", "kind": "adr", "title": "second", "slug": "shared" }),
5145 false,
5146 )
5147 .unwrap_err();
5148 assert!(matches!(error, TableError::Unique(_)));
5149 }
5150
5151 #[test]
5152 fn refuses_a_partition_value_that_cannot_name_a_file() {
5153 let dir = tempdir().unwrap();
5154 let mut notes = open_notes(dir.path());
5155 for kind in ["../escape", "Upper", "has space", ""] {
5156 let error = notes
5157 .put(&json!({ "id": "x", "kind": kind, "title": "nope" }), false)
5158 .unwrap_err();
5159 assert!(
5160 matches!(error, TableError::Partition(_)),
5161 "kind {kind:?} must be refused"
5162 );
5163 }
5164 assert!(!dir.path().join("notes").exists());
5165 }
5166
5167 #[test]
5168 fn refuses_to_move_a_record_between_partitions() {
5169 let dir = tempdir().unwrap();
5170 let mut notes = open_notes(dir.path());
5171 notes
5172 .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5173 .unwrap();
5174
5175 let put_error = notes
5176 .put(&json!({ "id": "n1", "kind": "adr", "title": "moved" }), false)
5177 .unwrap_err();
5178 assert!(matches!(put_error, TableError::Partition(_)));
5179 let patch_error = notes.patch("n1", &json!({ "kind": "adr" }), false).unwrap_err();
5180 assert!(matches!(patch_error, TableError::Partition(_)));
5181
5182 assert_eq!(notes.get("n1").unwrap().unwrap().get("kind"), Some(&json!("note")));
5183 assert!(!dir.path().join("notes/adr.jsonl").exists());
5184 }
5185
5186 #[test]
5187 fn refuses_a_staged_write_that_would_move_a_key_the_window_already_placed() {
5188 let dir = tempdir().unwrap();
5189 let mut table = open_notes(dir.path());
5190 table
5191 .stage_put(
5192 &json!({ "id": "n1", "kind": "note", "title": "Backend", "slug": "n1" }),
5193 false,
5194 )
5195 .unwrap();
5196
5197 let refused = table
5198 .stage_put(
5199 &json!({ "id": "n1", "kind": "adr", "title": "Backend", "slug": "n1" }),
5200 true,
5201 )
5202 .unwrap_err();
5203
5204 assert!(matches!(refused, TableError::Partition(_)));
5205 assert!(
5206 refused.to_string().contains("already lives in partition \"note\""),
5207 "the partition came from the window, which the registry knows nothing about: {refused}"
5208 );
5209 }
5210
5211 #[test]
5212 fn rewrites_a_record_inside_its_own_partition() {
5213 let dir = tempdir().unwrap();
5214 let mut notes = open_notes(dir.path());
5215 notes
5216 .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5217 .unwrap();
5218 notes.patch("n1", &json!({ "title": "second" }), false).unwrap();
5219
5220 assert_eq!(notes.get("n1").unwrap().unwrap().get("title"), Some(&json!("second")));
5221 assert_eq!(partition_lines(dir.path(), "note").len(), 2);
5222 }
5223
5224 #[test]
5225 fn puts_a_tombstone_in_the_partition_the_record_lived_in() {
5226 let dir = tempdir().unwrap();
5227 let mut notes = open_notes(dir.path());
5228 notes
5229 .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5230 .unwrap();
5231 notes
5232 .put(&json!({ "id": "a1", "kind": "adr", "title": "kept" }), false)
5233 .unwrap();
5234
5235 assert!(notes.delete("n1", false).unwrap());
5236 assert!(!notes.delete("nope", false).unwrap());
5237
5238 let ops: Vec<Value> = partition_lines(dir.path(), "note")
5239 .iter()
5240 .map(|line| line.get("_op").unwrap().clone())
5241 .collect();
5242 assert_eq!(ops, vec![json!("put"), json!("del")]);
5243 assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
5244 }
5245
5246 #[test]
5247 fn lands_a_unit_of_work_that_spans_two_partitions_of_one_table() {
5248 let dir = tempdir().unwrap();
5249 let mut notes = open_notes(dir.path());
5250 let prepared = notes
5251 .prepare_txn_ops(&[
5252 TxnOp::Put(json!({ "id": "n1", "kind": "note", "title": "from unit" })),
5253 TxnOp::Put(json!({ "id": "a1", "kind": "adr", "title": "from unit too" })),
5254 ])
5255 .unwrap();
5256 assert_eq!(prepared.len(), 2);
5257 notes.apply_txn_entries(prepared).unwrap();
5258
5259 assert_eq!(
5260 notes.get("n1").unwrap().unwrap().get("title"),
5261 Some(&json!("from unit"))
5262 );
5263 assert_eq!(
5264 notes.get("a1").unwrap().unwrap().get("title"),
5265 Some(&json!("from unit too"))
5266 );
5267 assert_eq!(partition_lines(dir.path(), "note").len(), 1);
5268 assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
5269 }
5270
5271 #[test]
5272 fn refuses_a_unit_of_work_that_would_move_a_record_between_partitions() {
5273 let dir = tempdir().unwrap();
5274 let mut notes = open_notes(dir.path());
5275 notes
5276 .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5277 .unwrap();
5278
5279 let outcome = notes.prepare_txn_ops(&[TxnOp::Put(json!({ "id": "n1", "kind": "adr", "title": "moved" }))]);
5280 assert!(matches!(outcome, Err(TableError::Partition(_))));
5281 assert_eq!(notes.get("n1").unwrap().unwrap().get("kind"), Some(&json!("note")));
5282 }
5283
5284 #[test]
5285 fn compacts_one_partition_and_leaves_the_others_byte_for_byte() {
5286 let dir = tempdir().unwrap();
5287 let mut notes = open_notes(dir.path());
5288 notes
5289 .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5290 .unwrap();
5291 notes
5292 .put(&json!({ "id": "a1", "kind": "adr", "title": "cold" }), false)
5293 .unwrap();
5294 for round in 0..8 {
5295 notes
5296 .patch("n1", &json!({ "title": format!("round {round}") }), false)
5297 .unwrap();
5298 }
5299
5300 let cold_before = fs::read_to_string(dir.path().join("notes/adr.jsonl")).unwrap();
5301 assert_eq!(partition_lines(dir.path(), "note").len(), 9);
5302
5303 let result = notes.compact().unwrap();
5304 assert_eq!(result.records, 2);
5305 assert_eq!(partition_lines(dir.path(), "note").len(), 1);
5306 assert_eq!(
5307 fs::read_to_string(dir.path().join("notes/adr.jsonl")).unwrap(),
5308 cold_before
5309 );
5310 assert_eq!(notes.get("n1").unwrap().unwrap().get("title"), Some(&json!("round 7")));
5311 assert_eq!(notes.get("a1").unwrap().unwrap().get("title"), Some(&json!("cold")));
5312 }
5313
5314 fn seeded_flat(dir: &std::path::Path) -> Table {
5315 let mut notes = Table::open(notes_schema(false), log_path(dir)).unwrap();
5316 for note in seed() {
5317 notes.put(¬e, false).unwrap();
5318 }
5319 notes.delete("n2", false).unwrap();
5320 notes
5321 }
5322
5323 #[test]
5324 fn moves_the_live_records_into_one_file_per_partition_when_the_schema_starts_partitioning() {
5325 let dir = tempdir().unwrap();
5326 drop(seeded_flat(dir.path()));
5327
5328 let notes = open_notes(dir.path());
5329 assert_eq!(notes.registry.records(), 3);
5330 assert_eq!(notes.get("n2").unwrap(), None);
5331 assert_eq!(
5332 notes.get("a1").unwrap().unwrap().get("title"),
5333 Some(&json!("Backend decision"))
5334 );
5335
5336 assert!(!log_path(dir.path()).exists());
5337 assert_eq!(partition_lines(dir.path(), "note").len(), 1);
5338 assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
5339 assert_eq!(partition_lines(dir.path(), "rule").len(), 1);
5340 }
5341
5342 fn open_notes_readonly(dir: &std::path::Path) -> (Table, RestoreOutcome) {
5343 Table::open_with_snapshot_and_declared_indexes(
5344 notes_schema(true),
5345 log_path(dir),
5346 dir.join("notes.idx"),
5347 "hash",
5348 0,
5349 true,
5350 &[],
5351 )
5352 .unwrap()
5353 }
5354
5355 #[test]
5356 fn a_readonly_open_reads_the_flat_log_the_move_has_not_touched_yet() {
5357 let dir = tempdir().unwrap();
5358 drop(seeded_flat(dir.path()));
5359 let before = fs::read_to_string(log_path(dir.path())).unwrap();
5360
5361 let (notes, _) = open_notes_readonly(dir.path());
5362
5363 assert_eq!(notes.registry.records(), 3);
5364 assert_eq!(notes.get("n2").unwrap(), None);
5365 assert_eq!(
5366 notes.get("a1").unwrap().unwrap().get("title"),
5367 Some(&json!("Backend decision"))
5368 );
5369 assert_eq!(
5370 fs::read_to_string(log_path(dir.path())).unwrap(),
5371 before,
5372 "a readonly open must leave the flat log byte for byte as it found it"
5373 );
5374 assert!(
5375 !dir.path().join("notes").exists(),
5376 "a readonly open must not create the partition directory"
5377 );
5378 }
5379
5380 #[test]
5381 fn a_readonly_open_of_the_unmoved_flat_log_writes_no_index_snapshot() {
5382 let dir = tempdir().unwrap();
5383 drop(seeded_flat(dir.path()));
5384
5385 let (notes, outcome) = open_notes_readonly(dir.path());
5386
5387 assert_eq!(outcome, RestoreOutcome::Rebuilt);
5388 assert_eq!(notes.registry.records(), 3);
5389 assert!(
5390 !dir.path().join("notes.idx").exists(),
5391 "a readonly open must not write the index snapshot"
5392 );
5393 }
5394
5395 #[test]
5396 fn a_writer_still_finishes_the_move_a_readonly_open_left_undone() {
5397 let dir = tempdir().unwrap();
5398 drop(seeded_flat(dir.path()));
5399 drop(open_notes_readonly(dir.path()));
5400
5401 let notes = open_notes(dir.path());
5402
5403 assert_eq!(notes.registry.records(), 3);
5404 assert_eq!(
5405 notes.get("a1").unwrap().unwrap().get("title"),
5406 Some(&json!("Backend decision"))
5407 );
5408 assert!(!log_path(dir.path()).exists());
5409 assert_eq!(partition_lines(dir.path(), "note").len(), 1);
5410 assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
5411 assert_eq!(partition_lines(dir.path(), "rule").len(), 1);
5412 }
5413
5414 #[test]
5415 fn a_readonly_open_reads_the_partitions_once_the_move_has_run() {
5416 let dir = tempdir().unwrap();
5417 drop(seeded_flat(dir.path()));
5418 drop(open_notes(dir.path()));
5419
5420 let (notes, _) = open_notes_readonly(dir.path());
5421
5422 assert!(notes.logs.is_partitioned());
5423 assert_eq!(notes.registry.records(), 3);
5424 assert_eq!(notes.get("r1").unwrap().unwrap().get("kind"), Some(&json!("rule")));
5425 }
5426
5427 #[test]
5428 fn keeps_full_text_results_identical_to_the_unpartitioned_table() {
5429 let dir = tempdir().unwrap();
5430 let flat_hits = {
5431 let flat = seeded_flat(dir.path());
5432 let spec = QueryBuilder::new().search("silnik", SearchMode::All).spec();
5433 let mut hits = flat.query(&spec).unwrap().keys;
5434 hits.sort();
5435 hits
5436 };
5437
5438 let notes = open_notes(dir.path());
5439 let spec = QueryBuilder::new().search("silnik", SearchMode::All).spec();
5440 let mut partitioned_hits = notes.query(&spec).unwrap().keys;
5441 partitioned_hits.sort();
5442
5443 assert!(!flat_hits.is_empty());
5444 assert_eq!(partitioned_hits, flat_hits);
5445 }
5446
5447 #[test]
5448 fn writes_and_reads_further_records_after_the_move() {
5449 let dir = tempdir().unwrap();
5450 drop(seeded_flat(dir.path()));
5451
5452 let mut notes = open_notes(dir.path());
5453 notes
5454 .put(
5455 &json!({ "id": "n3", "kind": "note", "title": "written after the move" }),
5456 false,
5457 )
5458 .unwrap();
5459 assert!(notes.delete("a1", false).unwrap());
5460
5461 assert_eq!(
5462 notes.get("n3").unwrap().unwrap().get("title"),
5463 Some(&json!("written after the move"))
5464 );
5465 assert_eq!(notes.get("a1").unwrap(), None);
5466 assert_eq!(partition_lines(dir.path(), "note").len(), 2);
5467 assert_eq!(partition_lines(dir.path(), "adr").len(), 2);
5468 }
5469
5470 #[test]
5471 fn an_unpartitioned_table_keeps_writing_the_single_flat_log() {
5472 let dir = tempdir().unwrap();
5473 let mut notes = Table::open(notes_schema(false), log_path(dir.path())).unwrap();
5474 for note in seed() {
5475 notes.put(¬e, false).unwrap();
5476 }
5477
5478 assert!(log_path(dir.path()).is_file());
5479 assert!(!dir.path().join("notes").exists());
5480 assert_eq!(
5481 fs::read_to_string(log_path(dir.path())).unwrap().trim().lines().count(),
5482 4
5483 );
5484 assert_eq!(notes.get("n1").unwrap().unwrap().get("slug"), Some(&json!("n1")));
5485 }
5486 }
5487
5488 mod read_cache {
5492 use std::io::Write as _;
5493
5494 use super::*;
5495
5496 const LARGE_BYTES: usize = 512;
5499
5500 fn policy(entries: usize, large_entries: usize) -> CachePolicy {
5501 CachePolicy::Enabled {
5502 entries,
5503 large_entries,
5504 large_bytes: LARGE_BYTES,
5505 }
5506 }
5507
5508 fn open_cached(cache: CachePolicy) -> (tempfile::TempDir, Table, PathBuf) {
5509 let (dir, mut table) = open_table();
5510 let path = dir.path().join("users.jsonl");
5511 table.set_cache(cache);
5512 (dir, table, path)
5513 }
5514
5515 fn seed(table: &mut Table, key: &str, name: &str) {
5516 table
5517 .put(&json!({ "id": key, "name": name, "age": 30 }), false)
5518 .unwrap();
5519 }
5520
5521 fn name_of(table: &Table, key: &str) -> Value {
5522 table.get(key).unwrap().unwrap().get("name").unwrap().clone()
5523 }
5524
5525 fn scribble(path: &Path) {
5529 let length = fs::metadata(path).unwrap().len() as usize;
5530 let mut file = fs::OpenOptions::new().write(true).open(path).unwrap();
5531 file.write_all(&vec![b'#'; length]).unwrap();
5532 }
5533
5534 #[test]
5535 fn a_second_get_of_a_cached_key_answers_without_touching_the_log_again() {
5536 let (_dir, mut table, path) = open_cached(policy(4, 1));
5537 seed(&mut table, "u1", "Ania");
5538 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5539
5540 scribble(&path);
5541
5542 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5543 }
5544
5545 #[test]
5546 fn without_a_cache_the_same_second_get_goes_back_to_the_log() {
5547 let (dir, mut table) = open_table();
5548 let path = dir.path().join("users.jsonl");
5549 seed(&mut table, "u1", "Ania");
5550 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5551
5552 scribble(&path);
5553
5554 assert!(
5555 matches!(table.get("u1"), Err(TableError::Io(_))),
5556 "an uncached read parses the log line every time"
5557 );
5558 }
5559
5560 #[test]
5561 fn a_table_nobody_configured_caches_nothing_and_reads_exactly_as_before() {
5562 let (_dir, mut table) = open_table();
5563 assert_eq!(table.cache_policy(), CachePolicy::Disabled);
5564
5565 seed(&mut table, "u1", "Ania");
5566 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5567 table.patch("u1", &json!({ "name": "Zosia" }), false).unwrap();
5568 assert_eq!(name_of(&table, "u1"), json!("Zosia"));
5569 assert!(table.delete("u1", false).unwrap());
5570 assert_eq!(table.get("u1").unwrap(), None);
5571 assert_eq!(table.cache.len(), (0, 0));
5572 }
5573
5574 #[test]
5575 fn a_put_over_a_cached_key_is_what_the_next_get_answers() {
5576 let (_dir, mut table, _path) = open_cached(policy(4, 1));
5577 seed(&mut table, "u1", "Ania");
5578 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5579
5580 seed(&mut table, "u1", "Zosia");
5581
5582 assert_eq!(name_of(&table, "u1"), json!("Zosia"));
5583 }
5584
5585 #[test]
5586 fn a_patch_over_a_cached_key_is_what_the_next_get_answers() {
5587 let (_dir, mut table, _path) = open_cached(policy(4, 1));
5588 seed(&mut table, "u1", "Ania");
5589 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5590
5591 table.patch("u1", &json!({ "age": 41 }), false).unwrap();
5592
5593 assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(41)));
5594 }
5595
5596 #[test]
5597 fn a_delete_of_a_cached_key_leaves_nothing_for_the_next_get() {
5598 let (_dir, mut table, _path) = open_cached(policy(4, 1));
5599 seed(&mut table, "u1", "Ania");
5600 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5601
5602 assert!(table.delete("u1", false).unwrap());
5603
5604 assert_eq!(table.get("u1").unwrap(), None);
5605 assert_eq!(table.cache.len(), (0, 0));
5606 }
5607
5608 #[test]
5609 fn the_limit_evicts_the_least_recently_read_key_and_it_still_reads_off_the_log() {
5610 let (_dir, mut table, _path) = open_cached(policy(2, 1));
5611 seed(&mut table, "u1", "Ania");
5612 seed(&mut table, "u2", "Piotr");
5613 seed(&mut table, "u3", "Zosia");
5614
5615 assert_eq!(name_of(&table, "u2"), json!("Piotr"));
5616 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5617 assert_eq!(name_of(&table, "u3"), json!("Zosia"));
5619
5620 assert_eq!(table.cache.len(), (2, 0));
5621 assert_eq!(
5622 name_of(&table, "u2"),
5623 json!("Piotr"),
5624 "the evicted key falls back to the log"
5625 );
5626 }
5627
5628 #[test]
5629 fn an_evicted_key_reaches_for_the_log_while_the_two_kept_ones_do_not() {
5630 let (_dir, mut table, path) = open_cached(policy(2, 1));
5631 seed(&mut table, "u1", "Ania");
5632 seed(&mut table, "u2", "Piotr");
5633 seed(&mut table, "u3", "Zosia");
5634 table.get("u2").unwrap();
5635 table.get("u1").unwrap();
5636 table.get("u3").unwrap();
5637
5638 scribble(&path);
5639
5640 assert!(matches!(table.get("u2"), Err(TableError::Io(_))));
5641 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5642 assert_eq!(name_of(&table, "u3"), json!("Zosia"));
5643 }
5644
5645 #[test]
5646 fn large_documents_share_their_own_smaller_tier_and_leave_the_small_ones_alone() {
5647 let (_dir, mut table, path) = open_cached(policy(2, 1));
5648 let long = "x".repeat(LARGE_BYTES);
5649 seed(&mut table, "u1", "Ania");
5650 seed(&mut table, "u2", "Piotr");
5651 seed(&mut table, "d1", &long);
5652 seed(&mut table, "d2", &long);
5653
5654 table.get("u1").unwrap();
5655 table.get("u2").unwrap();
5656 table.get("d1").unwrap();
5657 table.get("d2").unwrap();
5658
5659 assert_eq!(
5660 table.cache.len(),
5661 (2, 1),
5662 "two large documents share one slot; the small ones keep both of theirs"
5663 );
5664
5665 scribble(&path);
5666
5667 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5668 assert_eq!(name_of(&table, "u2"), json!("Piotr"));
5669 assert_eq!(name_of(&table, "d2"), json!(long));
5670 assert!(
5671 matches!(table.get("d1"), Err(TableError::Io(_))),
5672 "the older large document was the one evicted"
5673 );
5674 }
5675
5676 #[test]
5677 fn a_refresh_that_catches_a_foreign_write_drops_the_document_it_cached() {
5678 let (_dir, mut writer, mut follower) = two_handles();
5679 follower.set_cache(policy(4, 1));
5680 writer
5681 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
5682 .unwrap();
5683 follower.refresh().unwrap();
5684 assert_eq!(name_of(&follower, "u1"), json!("Ania"));
5685
5686 writer
5687 .put(&json!({ "id": "u1", "name": "Zosia", "age": 30 }), false)
5688 .unwrap();
5689
5690 assert_eq!(follower.refresh().unwrap(), RefreshOutcome::CaughtUp { entries: 1 });
5691 assert_eq!(name_of(&follower, "u1"), json!("Zosia"));
5692 }
5693
5694 #[test]
5695 fn a_following_read_of_a_cached_key_sees_the_foreign_write_it_caught_up_with() {
5696 let (_dir, mut writer, mut follower) = two_handles();
5697 follower.set_cache(policy(4, 1));
5698 follower.set_follow(Some(FollowPolicy {
5699 interval: Duration::ZERO,
5700 }));
5701 writer
5702 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
5703 .unwrap();
5704 assert_eq!(
5705 follower.get_following("u1").unwrap().unwrap().get("name"),
5706 Some(&json!("Ania"))
5707 );
5708
5709 writer
5710 .put(&json!({ "id": "u1", "name": "Zosia", "age": 30 }), false)
5711 .unwrap();
5712
5713 assert_eq!(
5714 follower.get_following("u1").unwrap().unwrap().get("name"),
5715 Some(&json!("Zosia"))
5716 );
5717 }
5718
5719 #[test]
5720 fn a_reload_after_a_foreign_compaction_throws_the_whole_cache_away() {
5721 let (_dir, mut writer, mut follower) = two_handles();
5722 follower.set_cache(policy(4, 1));
5723 writer
5724 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
5725 .unwrap();
5726 writer
5727 .put(&json!({ "id": "u2", "name": "Piotr", "age": 41 }), false)
5728 .unwrap();
5729 follower.refresh().unwrap();
5730 assert_eq!(name_of(&follower, "u1"), json!("Ania"));
5731 assert_eq!(name_of(&follower, "u2"), json!("Piotr"));
5732
5733 writer
5734 .put(&json!({ "id": "u1", "name": "Zosia", "age": 30 }), false)
5735 .unwrap();
5736 writer.compact().unwrap();
5737
5738 assert_eq!(follower.refresh().unwrap(), RefreshOutcome::Reloaded);
5739 assert_eq!(name_of(&follower, "u1"), json!("Zosia"));
5740 assert_eq!(name_of(&follower, "u2"), json!("Piotr"));
5741 }
5742
5743 #[test]
5744 fn compacting_this_handles_own_log_leaves_nothing_cached_behind() {
5745 let (_dir, mut table, _path) = open_cached(policy(4, 1));
5746 seed(&mut table, "u1", "Ania");
5747 seed(&mut table, "u2", "Piotr");
5748 seed(&mut table, "u1", "Zosia");
5749 table.get("u1").unwrap();
5750 table.get("u2").unwrap();
5751
5752 table.compact().unwrap();
5753
5754 assert_eq!(
5755 table.cache.len(),
5756 (0, 0),
5757 "every offset the cache was filled under moved"
5758 );
5759 assert_eq!(name_of(&table, "u1"), json!("Zosia"));
5760 assert_eq!(name_of(&table, "u2"), json!("Piotr"));
5761 }
5762
5763 #[test]
5764 fn a_unit_of_work_entry_applied_over_a_cached_key_invalidates_it() {
5765 let (_dir, mut table, _path) = open_cached(policy(4, 1));
5766 seed(&mut table, "u1", "Ania");
5767 assert_eq!(name_of(&table, "u1"), json!("Ania"));
5768
5769 let prepared = table
5770 .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Zosia", "age": 30 }))])
5771 .unwrap();
5772 table.apply_txn_entries(prepared).unwrap();
5773
5774 assert_eq!(name_of(&table, "u1"), json!("Zosia"));
5775 }
5776
5777 #[test]
5778 fn a_recovered_journal_line_over_a_cached_key_invalidates_it() {
5779 let (_dir, mut writer, mut follower) = two_handles();
5780 follower.set_cache(policy(4, 1));
5781 writer
5782 .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
5783 .unwrap();
5784 follower.refresh().unwrap();
5785 assert_eq!(name_of(&follower, "u1"), json!("Ania"));
5786
5787 let prepared = writer
5788 .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Zosia", "age": 30 }))])
5789 .unwrap();
5790 let lines: Vec<(u64, String)> = prepared
5791 .iter()
5792 .map(|entry| (entry.seq(), entry.line().to_string()))
5793 .collect();
5794 assert_eq!(follower.recover_txn_lines(&lines).unwrap(), 1);
5795
5796 assert_eq!(name_of(&follower, "u1"), json!("Zosia"));
5797 }
5798
5799 #[test]
5800 fn re_arming_the_cache_starts_it_cold() {
5801 let (_dir, mut table, path) = open_cached(policy(4, 1));
5802 seed(&mut table, "u1", "Ania");
5803 table.get("u1").unwrap();
5804
5805 table.set_cache(policy(4, 1));
5806 scribble(&path);
5807
5808 assert!(matches!(table.get("u1"), Err(TableError::Io(_))));
5809 }
5810 }
5811}