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