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