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