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