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