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