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