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