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