use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::catalog::{
CatalogCompaction, CatalogSyncMode, CatalogValidation, ExpandedIndex, LinkParsing, TriggerEntry, TriggerEvent,
};
use crate::checkpoint::{
remove_snapshot, restore_or_rebuild_set_with_declared, write_snapshot_set, RestoreOutcome, Restored,
};
use crate::crypto::{
assert_carried_seals, cypher_fields_of, envelope_key_id, hash_record, is_password_envelope, open_record,
password_fields_of, seal_record, verify_password, FieldCipher, MissingKeyPolicy, PasswordCheck, PasswordHashParams,
};
use crate::errors::{
HaysonCipherError, HaysonNotFoundError, HaysonPartitionError, HaysonPendingConflictError,
HaysonRelationRestrictError, HaysonRelationViolationError, HaysonSchemaError, HaysonUniqueViolationError,
HaysonValidationError,
};
use crate::query::aggregate::{
execute_aggregate, execute_grouped_aggregate, AggregateResult, AggregateSpec, GroupedAggregateResult,
};
use crate::query::builder::QueryBuilder;
use crate::query::planner::{
execute_access, execute_query, plan_access_overlaid, LogSetRecordSource, QueryOverlay, QueryResult, QuerySpec,
RecordSource, RecordSourceError,
};
use crate::queue::write_queue::{assert_partition_stable, assert_unique_in_unit, UnitShadow};
use crate::queue::{StageError, WriteOutcome, WriteQueue};
use crate::registry::fulltext_index::CorpusDelta;
use crate::registry::table_registry::TableRegistry;
use crate::schema::{
broken_links, is_partitioned, is_physical_path, partition_of, physical_path_of, primary_key_of, undeclared_fields,
validate_record, without_undeclared, FieldType, HaysonRecord, TableSchema, ValidateOptions, UNPARTITIONED,
};
use crate::storage::{
ensure_partitioned, parse_entry, partition_names_in, serialize_entry, LogEntry, LogMeta, LogOp, LogSet, ScanResult,
Slot,
};
use super::cache::{CachePolicy, RecordCache};
use super::trigger::{effect_document, matching, TriggerFiring};
#[derive(Debug)]
pub enum TableError {
Validation(HaysonValidationError),
Unique(HaysonUniqueViolationError),
Conflict(HaysonPendingConflictError),
NotFound(HaysonNotFoundError),
Schema(HaysonSchemaError),
Partition(HaysonPartitionError),
Relation(HaysonRelationViolationError),
RelationRestrict(HaysonRelationRestrictError),
Cipher(Box<HaysonCipherError>),
Io(io::Error),
}
impl fmt::Display for TableError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TableError::Validation(e) => e.fmt(f),
TableError::Unique(e) => e.fmt(f),
TableError::Conflict(e) => e.fmt(f),
TableError::NotFound(e) => e.fmt(f),
TableError::Schema(e) => e.fmt(f),
TableError::Partition(e) => e.fmt(f),
TableError::Relation(e) => e.fmt(f),
TableError::RelationRestrict(e) => e.fmt(f),
TableError::Cipher(e) => e.fmt(f),
TableError::Io(e) => e.fmt(f),
}
}
}
impl std::error::Error for TableError {}
impl From<RecordSourceError> for TableError {
fn from(value: RecordSourceError) -> Self {
match value {
RecordSourceError::Cipher(error) => TableError::Cipher(error),
RecordSourceError::Io(error) => TableError::Io(error),
}
}
}
impl From<StageError> for TableError {
fn from(value: StageError) -> Self {
match value {
StageError::Validation(e) => TableError::Validation(e),
StageError::Unique(e) => TableError::Unique(e),
StageError::Conflict(e) => TableError::Conflict(e),
StageError::Partition(e) => TableError::Partition(e),
}
}
}
impl From<io::Error> for TableError {
fn from(value: io::Error) -> Self {
TableError::Io(value)
}
}
impl From<HaysonValidationError> for TableError {
fn from(value: HaysonValidationError) -> Self {
TableError::Validation(value)
}
}
impl From<HaysonPendingConflictError> for TableError {
fn from(value: HaysonPendingConflictError) -> Self {
TableError::Conflict(value)
}
}
impl From<HaysonUniqueViolationError> for TableError {
fn from(value: HaysonUniqueViolationError) -> Self {
TableError::Unique(value)
}
}
impl From<HaysonPartitionError> for TableError {
fn from(value: HaysonPartitionError) -> Self {
TableError::Partition(value)
}
}
impl From<HaysonCipherError> for TableError {
fn from(value: HaysonCipherError) -> Self {
TableError::Cipher(Box::new(value))
}
}
impl From<TableError> for io::Error {
fn from(value: TableError) -> Self {
match value {
TableError::Io(error) => error,
other => io::Error::other(other.to_string()),
}
}
}
impl From<crate::queue::CommitError> for TableError {
fn from(value: crate::queue::CommitError) -> Self {
match value {
crate::queue::CommitError::Io(error) => TableError::Io(error),
crate::queue::CommitError::Cipher(error) => TableError::Cipher(Box::new(error)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrokenLink {
pub field: String,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageReference {
pub key: String,
pub field: String,
pub virtual_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageInfo {
pub physical_path: String,
pub bytes: u64,
pub referenced_by: Vec<ImageReference>,
}
impl ImageInfo {
pub fn is_missing(&self) -> bool {
self.bytes == 0 && !self.referenced_by.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ImageUsage {
pub count: u64,
pub bytes: u64,
pub orphan_count: u64,
pub orphan_bytes: u64,
}
pub struct PutOutcome {
pub record: HaysonRecord,
pub warnings: Vec<BrokenLink>,
}
impl PutOutcome {
fn into_record(self) -> HaysonRecord {
self.record
}
}
impl From<PutOutcome> for HaysonRecord {
fn from(outcome: PutOutcome) -> Self {
outcome.record
}
}
enum RotationVerdict {
Current,
Rotate,
Missing(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RotationResult {
pub rotated: usize,
pub already_current: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshOutcome {
Unchanged,
CaughtUp { entries: usize },
Reloaded,
}
impl RefreshOutcome {
pub fn changed(self) -> bool {
self != RefreshOutcome::Unchanged
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FollowPolicy {
pub interval: Duration,
}
impl Default for FollowPolicy {
fn default() -> Self {
FollowPolicy {
interval: Duration::ZERO,
}
}
}
struct StagedWrite {
id: u64,
record: Option<HaysonRecord>,
old: Option<HaysonRecord>,
live: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TableStats {
pub records: usize,
pub live_bytes: u64,
pub dead_bytes: u64,
pub log_bytes: u64,
pub queue_depth: usize,
pub gen: u64,
pub seq: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct HistoryEntry {
pub seq: u64,
pub ts: i64,
pub op: LogOp,
pub partition: String,
pub offset: u64,
pub length: u64,
pub document: Option<HaysonRecord>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TableHistory {
pub entries: Vec<HistoryEntry>,
pub scanned: Vec<PathBuf>,
}
fn value_as_key(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn project_onto_schema(schema: &TableSchema, record: HaysonRecord) -> HaysonRecord {
if record.keys().all(|field| schema.fields.contains_key(field)) {
return record;
}
record
.into_iter()
.filter(|(field, _)| schema.fields.contains_key(field))
.collect()
}
fn default_image_dir(log_path: &Path, table: &str) -> PathBuf {
let images = format!("{table}.images");
match log_path.parent() {
Some(parent) => parent.join(images),
None => PathBuf::from(images),
}
}
pub struct Table {
schema: TableSchema,
registry: TableRegistry,
logs: LogSet,
queue: WriteQueue,
staged: HashMap<String, StagedWrite>,
next_op_id: u64,
seq: u64,
snapshot_path: Option<PathBuf>,
schema_hash: String,
snapshot_every: u64,
since_snapshot: u64,
snapshot_current: bool,
compaction: CatalogCompaction,
sync: CatalogSyncMode,
validation: CatalogValidation,
deprecated_fields: BTreeMap<String, String>,
triggers: Vec<TriggerEntry>,
image_dir: PathBuf,
link_parsing: LinkParsing,
max_image_bytes: Option<u64>,
cipher: Option<FieldCipher>,
cypher_fields: Vec<String>,
password_fields: Vec<String>,
password_params: PasswordHashParams,
declared: Vec<ExpandedIndex>,
gen: u64,
follow: Option<FollowPolicy>,
followed_at: Option<Instant>,
cache: RecordCache,
}
impl Table {
pub fn open(schema: TableSchema, path: impl AsRef<Path>) -> io::Result<Self> {
Self::open_with_declared_indexes(schema, path, &[])
}
pub fn open_with_declared_indexes(
schema: TableSchema,
path: impl AsRef<Path>,
declared: &[ExpandedIndex],
) -> io::Result<Self> {
let image_dir = default_image_dir(path.as_ref(), &schema.table);
let cypher_fields = cypher_fields_of(&schema);
let password_fields = password_fields_of(&schema);
let mut logs = open_logs(&schema, path.as_ref(), false)?;
let registry = TableRegistry::build_from_log_set_with_declared(schema.clone(), &mut logs, declared)?;
let seq = registry.max_seq();
let queue = WriteQueue::new(schema.table.clone());
Ok(Table {
schema,
registry,
logs,
queue,
staged: HashMap::new(),
next_op_id: 0,
seq,
snapshot_path: None,
schema_hash: String::new(),
snapshot_every: 0,
since_snapshot: 0,
snapshot_current: false,
compaction: CatalogCompaction::Disabled,
sync: CatalogSyncMode::Commit,
validation: CatalogValidation::Strict,
deprecated_fields: BTreeMap::new(),
triggers: Vec::new(),
image_dir,
link_parsing: LinkParsing::Eager,
max_image_bytes: None,
cipher: None,
cypher_fields,
password_fields,
password_params: PasswordHashParams::default(),
declared: declared.to_vec(),
gen: 0,
follow: None,
followed_at: None,
cache: RecordCache::new(CachePolicy::Disabled),
})
}
pub fn open_with_snapshot(
schema: TableSchema,
path: impl AsRef<Path>,
snapshot_path: impl AsRef<Path>,
schema_hash: &str,
snapshot_every: u64,
) -> io::Result<(Self, RestoreOutcome)> {
Self::open_with_snapshot_and_declared_indexes(
schema,
path,
snapshot_path,
schema_hash,
snapshot_every,
false,
&[],
)
}
#[allow(clippy::too_many_arguments)]
pub fn open_with_snapshot_and_declared_indexes(
schema: TableSchema,
path: impl AsRef<Path>,
snapshot_path: impl AsRef<Path>,
schema_hash: &str,
snapshot_every: u64,
readonly: bool,
declared: &[ExpandedIndex],
) -> io::Result<(Self, RestoreOutcome)> {
let image_dir = default_image_dir(path.as_ref(), &schema.table);
let cypher_fields = cypher_fields_of(&schema);
let password_fields = password_fields_of(&schema);
let mut logs = open_logs(&schema, path.as_ref(), readonly)?;
let Restored { registry, outcome, gen } = restore_or_rebuild_set_with_declared(
snapshot_path.as_ref(),
schema.clone(),
&mut logs,
schema_hash,
declared,
)?;
let seq = registry.max_seq();
let queue = WriteQueue::new(schema.table.clone());
let table = Table {
schema,
registry,
logs,
queue,
staged: HashMap::new(),
next_op_id: 0,
seq,
snapshot_path: Some(snapshot_path.as_ref().to_path_buf()),
schema_hash: schema_hash.to_string(),
snapshot_every,
since_snapshot: 0,
snapshot_current: outcome == RestoreOutcome::Restored,
compaction: CatalogCompaction::Disabled,
sync: CatalogSyncMode::Commit,
validation: CatalogValidation::Strict,
deprecated_fields: BTreeMap::new(),
triggers: Vec::new(),
image_dir,
link_parsing: LinkParsing::Eager,
max_image_bytes: None,
cipher: None,
cypher_fields,
password_fields,
password_params: PasswordHashParams::default(),
declared: declared.to_vec(),
gen,
follow: None,
followed_at: None,
cache: RecordCache::new(CachePolicy::Disabled),
};
Ok((table, outcome))
}
pub fn set_triggers(&mut self, triggers: Vec<TriggerEntry>) {
self.triggers = triggers;
}
pub fn triggers(&self) -> &[TriggerEntry] {
&self.triggers
}
pub fn set_image_dir(&mut self, dir: PathBuf) {
self.image_dir = dir;
}
pub fn image_dir(&self) -> &Path {
&self.image_dir
}
pub fn set_max_image_bytes(&mut self, max: Option<u64>) {
self.max_image_bytes = max;
}
pub fn set_password_hash_params(&mut self, params: PasswordHashParams) {
self.password_params = params;
}
pub fn verify_password(&self, key: &str, field: &str, candidate: &str) -> Result<PasswordCheck, TableError> {
if !self.password_fields.iter().any(|name| name == field) {
return Err(TableError::Schema(HaysonSchemaError(format!(
"field \"{field}\" of table \"{}\" is not declared \"passwordHash\"",
self.schema.table
))));
}
let record = self.get(key)?;
Ok(verify_password(
record.as_ref().and_then(|record| record.get(field)),
candidate,
&self.password_params,
))
}
pub fn set_link_parsing(&mut self, mode: LinkParsing) {
self.link_parsing = mode;
}
pub fn link_parsing(&self) -> LinkParsing {
self.link_parsing
}
pub fn set_validation(&mut self, mode: CatalogValidation) {
self.validation = mode;
}
pub fn validation(&self) -> CatalogValidation {
self.validation
}
pub(crate) fn accepted_document<'a>(&self, document: &'a Value) -> Cow<'a, Value> {
match self.validation {
CatalogValidation::Strict => Cow::Borrowed(document),
CatalogValidation::Warn => without_undeclared(&self.schema, document),
}
}
pub fn dropped_field_warnings(&self, document: &Value) -> Vec<String> {
if self.validation != CatalogValidation::Warn {
return Vec::new();
}
undeclared_fields(&self.schema, document)
.into_iter()
.map(|field| format!("field \"{field}\": is not declared in the schema, dropped from the write"))
.collect()
}
pub fn set_deprecated_fields(&mut self, fields: BTreeMap<String, String>) {
self.deprecated_fields = fields;
}
pub fn deprecated_fields(&self) -> &BTreeMap<String, String> {
&self.deprecated_fields
}
pub fn deprecated_field_warnings(&self, document: &Value) -> Vec<String> {
if self.deprecated_fields.is_empty() {
return Vec::new();
}
let Some(source) = document.as_object() else {
return Vec::new();
};
source
.keys()
.filter_map(|field| self.deprecated_fields.get_key_value(field.as_str()))
.map(|(field, note)| {
if note.is_empty() {
format!("field \"{field}\": is deprecated")
} else {
format!("field \"{field}\": is deprecated — {note}")
}
})
.collect()
}
pub fn set_compaction(&mut self, compaction: CatalogCompaction) {
self.compaction = compaction;
}
pub fn compaction(&self) -> CatalogCompaction {
self.compaction
}
pub fn set_cache(&mut self, cache: CachePolicy) {
self.cache = RecordCache::new(cache);
}
pub fn cache_policy(&self) -> CachePolicy {
self.cache.policy()
}
pub fn set_sync(&mut self, sync: CatalogSyncMode) {
self.sync = sync;
self.logs.set_fsync(sync == CatalogSyncMode::Commit);
}
pub fn sync_mode(&self) -> CatalogSyncMode {
self.sync
}
pub fn set_cipher(&mut self, cipher: Option<FieldCipher>) {
self.cipher = cipher;
self.cache.clear();
}
fn write_image_fields(&self, source: &Value) -> io::Result<()> {
for decoded in self.decoded_images_of(source, false)? {
self.write_image_bytes(&decoded)?;
}
Ok(())
}
fn decoded_images_of(&self, source: &Value, lenient: bool) -> io::Result<Vec<Vec<u8>>> {
let Some(object) = source.as_object() else {
return Ok(Vec::new());
};
let mut decoded = Vec::new();
for (name, spec) in &self.schema.fields {
if spec.field_type != FieldType::Image {
continue;
}
let Some(content) = object
.get(name)
.and_then(|value| value.get("content"))
.and_then(Value::as_str)
else {
continue;
};
match crate::schema::decode_image_content(content) {
Ok(bytes) => decoded.push(bytes),
Err(_) if lenient => {}
Err(reason) => return Err(io::Error::new(io::ErrorKind::InvalidData, reason)),
}
}
Ok(decoded)
}
fn refuse_image_collisions(&self, source: &Value) -> io::Result<()> {
let decoded = self.decoded_images_of(source, true)?;
for (index, bytes) in decoded.iter().enumerate() {
if let Some(max) = self.max_image_bytes {
if bytes.len() as u64 > max {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
crate::errors::HaysonImageTooLargeError {
bytes: bytes.len() as u64,
max_bytes: max,
},
));
}
}
let name = physical_path_of(bytes);
let clash = |_: ()| {
io::Error::new(
io::ErrorKind::InvalidData,
crate::errors::HaysonImageClashError {
physical_path: name.clone(),
},
)
};
let earlier_in_document = decoded[..index]
.iter()
.any(|other| physical_path_of(other) == name && other != bytes);
if earlier_in_document {
return Err(clash(()));
}
let path = self.image_dir.join(&name);
if path.exists() && fs::read(&path)? != *bytes {
return Err(clash(()));
}
}
Ok(())
}
fn broken_markdown_links(&self, record: &HaysonRecord) -> Vec<BrokenLink> {
let mut broken = Vec::new();
for (name, spec) in &self.schema.fields {
if spec.field_type != FieldType::Markdown {
continue;
}
let Some(content) = record
.get(name)
.and_then(|value| value.get("content"))
.and_then(Value::as_str)
else {
continue;
};
let field_broken = broken_links(content, |target| {
self.registry
.virtual_path_fields()
.any(|field| self.registry.virtual_path_holder(field, target).is_some())
});
broken.extend(field_broken.into_iter().map(|target| BrokenLink {
field: name.clone(),
path: target,
}));
}
broken
}
fn eager_link_warnings(&self, record: &HaysonRecord) -> Vec<BrokenLink> {
if self.link_parsing != LinkParsing::Eager {
return Vec::new();
}
self.broken_markdown_links(record)
}
pub fn eager_link_warnings_of(&self, key: &str) -> Result<Vec<BrokenLink>, TableError> {
if self.link_parsing != LinkParsing::Eager {
return Ok(Vec::new());
}
self.broken_links_of(key)
}
pub fn broken_links_of(&self, key: &str) -> Result<Vec<BrokenLink>, TableError> {
let record = self.get(key)?.ok_or_else(|| {
TableError::NotFound(HaysonNotFoundError {
table: self.schema.table.clone(),
key: key.to_string(),
})
})?;
Ok(self.broken_markdown_links(&record))
}
pub fn read_image(&self, physical_path: &str) -> io::Result<Option<Vec<u8>>> {
self.image_path_of(physical_path)?;
match fs::read(self.image_dir.join(physical_path)) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
pub fn read_image_base64(&self, physical_path: &str) -> io::Result<Option<String>> {
Ok(self
.read_image(physical_path)?
.map(|bytes| crate::schema::encode_image_content(&bytes)))
}
pub fn list_images(&self) -> io::Result<Vec<ImageInfo>> {
let mut files = BTreeMap::new();
if let Ok(entries) = fs::read_dir(&self.image_dir) {
for entry in entries.filter_map(Result::ok) {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_file() && is_physical_path(&name) {
files.insert(name, metadata.len());
}
}
}
let mut referenced: BTreeMap<String, Vec<ImageReference>> = BTreeMap::new();
let image_fields: Vec<&String> = self
.schema
.fields
.iter()
.filter(|(_, spec)| spec.field_type == FieldType::Image)
.map(|(name, _)| name)
.collect();
if !image_fields.is_empty() {
let keys: Vec<String> = self.keys().cloned().collect();
for key in keys {
let Some(record) = self.get(&key).map_err(|error| io::Error::other(error.to_string()))? else {
continue;
};
for field in &image_fields {
let Some(Value::Object(image)) = record.get(field.as_str()) else {
continue;
};
let Some(Value::String(path)) = image.get("physicalPath") else {
continue;
};
referenced.entry(path.clone()).or_default().push(ImageReference {
key: key.clone(),
field: (*field).clone(),
virtual_path: image.get("virtualPath").and_then(Value::as_str).map(str::to_string),
});
}
}
}
let mut images: Vec<ImageInfo> = files
.into_iter()
.map(|(physical_path, bytes)| ImageInfo {
referenced_by: referenced.remove(&physical_path).unwrap_or_default(),
physical_path,
bytes,
})
.collect();
for (physical_path, referenced_by) in referenced {
images.push(ImageInfo {
physical_path,
bytes: 0,
referenced_by,
});
}
images.sort_by(|a, b| a.physical_path.cmp(&b.physical_path));
Ok(images)
}
pub fn image_usage(&self) -> io::Result<ImageUsage> {
let mut usage = ImageUsage::default();
for image in self.list_images()? {
if image.is_missing() {
continue;
}
usage.count += 1;
usage.bytes += image.bytes;
if image.referenced_by.is_empty() {
usage.orphan_count += 1;
usage.orphan_bytes += image.bytes;
}
}
Ok(usage)
}
pub fn delete_image(&self, physical_path: &str) -> io::Result<bool> {
self.image_path_of(physical_path)?;
let images = self.list_images()?;
if let Some(image) = images.iter().find(|image| image.physical_path == physical_path) {
if let Some(first) = image.referenced_by.first() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"image \"{physical_path}\" is still referenced by record \"{}\" (field \"{}\") — remove the reference first",
first.key, first.field
),
));
}
}
match fs::remove_file(self.image_dir.join(physical_path)) {
Ok(()) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error),
}
}
fn image_path_of(&self, physical_path: &str) -> io::Result<()> {
if is_physical_path(physical_path) {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"\"{physical_path}\" is not a content-hash file name this engine wrote (\"<16 hex digits>.bin\")"
),
))
}
}
fn write_image_bytes(&self, bytes: &[u8]) -> io::Result<()> {
let file_path = self.image_dir.join(physical_path_of(bytes));
if file_path.exists() {
return Ok(());
}
fs::create_dir_all(&self.image_dir)?;
fs::write(file_path, bytes)
}
pub fn snapshot(&mut self) -> io::Result<()> {
self.flush()?;
self.write_snapshot_now()
}
fn write_snapshot_now(&mut self) -> io::Result<()> {
let Some(path) = self.snapshot_path.clone() else {
return Ok(());
};
self.logs.refuse_if_replaced()?;
write_snapshot_set(
&path,
&self.registry,
&self.schema.table,
&self.schema_hash,
self.gen,
&self.logs,
)?;
self.since_snapshot = 0;
self.snapshot_current = true;
Ok(())
}
pub fn needs_snapshot(&self) -> bool {
self.snapshot_path.is_some() && !self.snapshot_current
}
pub fn rotate_cypher_keys(&mut self) -> Result<RotationResult, TableError> {
let mut result = RotationResult::default();
if self.cypher_fields.is_empty() {
return Ok(result);
}
if self.logs.is_readonly() {
return Err(TableError::Io(io::Error::other(format!(
"table \"{}\" is readonly — rotating cypher keys is not available",
self.schema.table
))));
}
self.flush()?;
let Some(cipher) = self.cipher.clone() else {
return Err(TableError::Cipher(Box::new(HaysonCipherError::new(
self.schema.table.clone(),
"",
"cannot rotate cypher keys without a key",
))));
};
let keys: Vec<String> = self.keys().cloned().collect();
for key in keys {
let Some(sealed) = self.sealed_record(&key)? else {
continue;
};
match self.rotation_verdict(&sealed, &cipher) {
RotationVerdict::Current => result.already_current += 1,
RotationVerdict::Missing(key_id) => match cipher.on_missing_key() {
MissingKeyPolicy::Throw => {
return Err(TableError::Cipher(Box::new(
HaysonCipherError::new(
self.schema.table.clone(),
"",
format!("record \"{key}\" was sealed with key \"{key_id}\", which was not given"),
)
.with_key_id(&key_id),
)))
}
MissingKeyPolicy::Omit | MissingKeyPolicy::Raw => result.skipped += 1,
},
RotationVerdict::Rotate => {
let opened = open_record(&self.schema, &key, &sealed, Some(&cipher), &self.cypher_fields)
.map_err(|error| TableError::Cipher(Box::new(error)))?;
let document = Value::Object(opened.into_iter().collect());
self.put_from_buffer(&document, false)?;
result.rotated += 1;
}
}
}
Ok(result)
}
fn sealed_record(&self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
let mut source = LogSetRecordSource::new(&self.registry, &self.logs);
source.load(key).map_err(TableError::from)
}
fn rotation_verdict(&self, sealed: &HaysonRecord, cipher: &FieldCipher) -> RotationVerdict {
let mut rotate = false;
for field in &self.cypher_fields {
let Some(value) = sealed.get(field) else { continue };
if value.is_null() {
continue;
}
match envelope_key_id(value) {
Some(key_id) if !cipher.knows(key_id) => return RotationVerdict::Missing(key_id.to_string()),
Some(key_id) => rotate |= key_id != cipher.write_key_id(),
None => rotate = true,
}
}
if rotate {
RotationVerdict::Rotate
} else {
RotationVerdict::Current
}
}
pub fn compact(&mut self) -> io::Result<crate::compaction::CompactionResult> {
self.flush()?;
self.cache.clear();
let mut total = crate::compaction::CompactionResult {
slots: HashMap::new(),
records: 0,
bytes_before: 0,
bytes_after: 0,
reclaimed_bytes: 0,
};
let mut swapped = false;
for part in self.logs.parts() {
if !swapped {
if let Some(path) = self.snapshot_path.as_deref() {
remove_snapshot(path)?;
}
self.snapshot_current = false;
swapped = true;
}
let Some(log) = self.logs.log_of_mut(&part) else {
continue;
};
let result = crate::compaction::compact_log(log)?;
self.registry.remap(&result.slots, &part);
total.records += result.records;
total.bytes_before += result.bytes_before;
total.bytes_after += result.bytes_after;
total.reclaimed_bytes += result.reclaimed_bytes;
total.slots.extend(result.slots);
}
if swapped {
self.gen += 1;
self.snapshot()?;
}
Ok(total)
}
pub fn refresh(&mut self) -> io::Result<RefreshOutcome> {
let appeared = self.logs.discover()?;
let mut entries = 0;
for part in self.logs.parts() {
if appeared.contains(&part) {
let Some(log) = self.logs.log_of(&part) else { continue };
let scan = log.scan(0)?;
entries += scan.entries.len();
self.invalidate_scanned(&scan);
self.registry.apply_scan_in(scan, &part);
continue;
}
let Some(log) = self.logs.log_of_mut(&part) else {
continue;
};
let Some(probe) = log.probe() else { continue };
if log.replaced_by(&probe) {
self.reload()?;
return Ok(RefreshOutcome::Reloaded);
}
if probe.size == log.size() {
continue;
}
let from = log.size();
log.adopt_size(probe.size);
let scan = log.scan(from)?;
log.adopt_size(scan.end_offset);
entries += scan.entries.len();
self.invalidate_scanned(&scan);
self.registry.apply_scan_in(scan, &part);
}
self.seq = self.seq.max(self.registry.max_seq());
if entries == 0 {
return Ok(RefreshOutcome::Unchanged);
}
self.snapshot_current = false;
Ok(RefreshOutcome::CaughtUp { entries })
}
fn invalidate_scanned(&self, scan: &ScanResult) {
for found in &scan.entries {
self.cache.invalidate(&found.entry.meta.key);
}
}
pub fn set_follow(&mut self, follow: Option<FollowPolicy>) {
self.follow = follow;
}
pub fn follow(&self) -> Option<FollowPolicy> {
self.follow
}
pub fn follow_if_due(&mut self) -> io::Result<Option<RefreshOutcome>> {
if !self.follow_is_due() {
return Ok(None);
}
self.followed_at = Some(Instant::now());
Ok(Some(self.refresh()?))
}
pub fn follow_is_due(&self) -> bool {
let Some(policy) = self.follow else { return false };
policy.interval.is_zero() || self.followed_at.is_none_or(|at| at.elapsed() >= policy.interval)
}
pub fn has_staged_writes(&self) -> bool {
self.queue.depth() > 0 || !self.staged.is_empty()
}
pub fn get_following(&mut self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
self.follow_if_due()?;
self.get(key)
}
pub fn get_many_following(&mut self, keys: &[String]) -> Result<Vec<Option<HaysonRecord>>, TableError> {
self.follow_if_due()?;
keys.iter().map(|key| self.get(key)).collect()
}
pub fn query_following(&mut self, spec: &QuerySpec) -> Result<QueryResult, TableError> {
self.flush()?;
self.follow_if_due()?;
self.query(spec)
}
pub fn aggregate_following(
&mut self,
spec: &QuerySpec,
aggregate: &AggregateSpec,
) -> Result<AggregateResult, TableError> {
self.flush()?;
self.follow_if_due()?;
self.aggregate(spec, aggregate)
}
pub fn aggregate_grouped_following(
&mut self,
spec: &QuerySpec,
aggregate: &AggregateSpec,
) -> Result<GroupedAggregateResult, TableError> {
self.flush()?;
self.follow_if_due()?;
self.aggregate_grouped(spec, aggregate)
}
fn reload(&mut self) -> io::Result<()> {
self.logs.discover()?;
let mut registry = TableRegistry::with_declared_indexes(self.schema.clone(), &self.declared);
for part in self.logs.parts() {
let Some(log) = self.logs.log_of_mut(&part) else {
continue;
};
log.reopen()?;
let scan = log.scan(0)?;
log.adopt_size(scan.end_offset);
registry.apply_scan_in(scan, &part);
}
self.registry = registry;
self.cache.clear();
self.seq = self.seq.max(self.registry.max_seq());
self.gen += 1;
self.snapshot_current = false;
Ok(())
}
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.registry.keys()
}
pub fn has(&self, key: &str) -> bool {
match self.staged.get(key) {
Some(staged) => staged.record.is_some(),
None => self.registry.slot_of(key).is_some(),
}
}
pub fn contains_value(&self, field: &str, value: &Value) -> bool {
if field == self.schema.primary_key {
return match value {
Value::String(s) => self.has(s),
Value::Number(n) => self.has(&n.to_string()),
_ => false,
};
}
self.registry.unique_holder(field, value).is_some()
}
pub fn keys_referencing(&self, field: &str, value: &Value) -> Result<Vec<String>, TableError> {
if let Some(keys) = self.registry.lookup_exact(field, value) {
return Ok(keys.iter().cloned().collect());
}
let all = self.query(&QueryBuilder::new().spec())?;
let mut matches = Vec::new();
for key in &all.keys {
if let Some(record) = self.get(key)? {
if record.get(field) == Some(value) {
matches.push(key.clone());
}
}
}
Ok(matches)
}
pub fn len(&self) -> usize {
self.registry.records()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn pending(&self, key: &str) -> HashSet<String> {
self.queue.pending(key)
}
pub fn stats(&self) -> TableStats {
let stats = self.registry.stats();
TableStats {
records: stats.records,
live_bytes: stats.live_bytes,
dead_bytes: stats.dead_bytes,
log_bytes: self.logs.total_size(),
queue_depth: self.queue.depth(),
gen: self.gen,
seq: self.seq,
}
}
fn note_writes(&mut self, count: usize) -> io::Result<()> {
if count == 0 {
return Ok(());
}
self.since_snapshot += count as u64;
self.snapshot_current = false;
self.compact_if_due()?;
if self.snapshot_every > 0 && self.since_snapshot >= self.snapshot_every {
self.write_snapshot_now()?;
}
Ok(())
}
fn compact_if_due(&mut self) -> io::Result<()> {
let CatalogCompaction::Enabled {
dead_bytes_ratio,
min_dead_bytes,
} = self.compaction
else {
return Ok(());
};
let mut compacted = false;
for part in self.logs.parts() {
let bytes = self.registry.stats_of(&part);
let total = bytes.live_bytes + bytes.dead_bytes;
if total == 0 || bytes.dead_bytes < min_dead_bytes {
continue;
}
if (bytes.dead_bytes as f64) / (total as f64) < dead_bytes_ratio {
continue;
}
let Some(log) = self.logs.log_of_mut(&part) else {
continue;
};
if !compacted {
if let Some(path) = self.snapshot_path.as_deref() {
remove_snapshot(path)?;
}
self.snapshot_current = false;
}
let result = crate::compaction::compact_log(log)?;
self.registry.remap(&result.slots, &part);
compacted = true;
}
if !compacted {
return Ok(());
}
self.cache.clear();
self.gen += 1;
self.write_snapshot_now()
}
pub fn name(&self) -> &str {
&self.schema.table
}
pub fn schema(&self) -> &TableSchema {
&self.schema
}
pub fn declared_indexes(&self) -> &[ExpandedIndex] {
&self.declared
}
pub fn get(&self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
match self.staged.get(key) {
Some(staged) => Ok(staged.record.clone()),
None => self.committed(key),
}
}
fn committed(&self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
if let Some(cached) = self.cache.get(key) {
return Ok(Some(cached));
}
let found = self.with_record_source(|source| source.load(key))?;
if let Some(record) = &found {
self.cache.insert(key, record);
}
Ok(found)
}
fn with_record_source<T>(
&self,
f: impl FnOnce(&mut dyn RecordSource) -> Result<T, RecordSourceError>,
) -> Result<T, TableError> {
let open = |key: &str, record: HaysonRecord| -> Result<HaysonRecord, RecordSourceError> {
let opened = if self.cypher_fields.is_empty() {
record
} else {
open_record(&self.schema, key, &record, self.cipher.as_ref(), &self.cypher_fields)?
};
Ok(project_onto_schema(&self.schema, opened))
};
let mut source = LogSetRecordSource::with_open(&self.registry, &self.logs, &open);
f(&mut source).map_err(TableError::from)
}
pub fn history(&self, key: &str) -> io::Result<TableHistory> {
let mut entries = Vec::new();
let mut scanned = Vec::new();
for part in self.logs.parts() {
let Some(log) = self.logs.log_of(&part) else { continue };
scanned.push(log.path().to_path_buf());
for found in log.scan(0)?.entries {
if found.entry.meta.key != key {
continue;
}
entries.push(HistoryEntry {
seq: found.entry.meta.seq,
ts: found.entry.meta.ts,
op: found.entry.meta.op,
partition: part.clone(),
offset: found.slot.offset,
length: found.slot.length,
document: found.entry.data,
});
}
}
entries.sort_by_key(|entry| entry.seq);
Ok(TableHistory { entries, scanned })
}
pub fn put(&mut self, document: &Value, force: bool) -> Result<HaysonRecord, TableError> {
self.put_impl(document, force, true).map(PutOutcome::into_record)
}
pub fn put_sealed(&mut self, document: &Value, sealed: &[String], force: bool) -> Result<HaysonRecord, TableError> {
if sealed.is_empty() {
return self.put(document, force);
}
let accepted = self.accepted_document(document);
let document = accepted.as_ref();
let candidate = validate_record(&self.schema, document, ValidateOptions::default())?;
let key = primary_key_of(&self.schema, &candidate)?;
for field in sealed {
if !self.cypher_fields.iter().any(|name| name == field) {
return Err(TableError::Cipher(Box::new(HaysonCipherError::new(
self.schema.table.clone(),
field.clone(),
"cannot be carried over as already sealed — it is not declared \"cypher\"",
))));
}
}
self.refuse_image_collisions(document)?;
assert_carried_seals(&self.schema, &key, &candidate, self.cipher.as_ref(), sealed)?;
self.assert_partition_stable_against_staged(document)?;
let key = self
.queue
.stage_put_carrying(&self.schema, &self.registry, document, sealed.to_vec(), force)?;
let Some(record) = self.queue.last_put_record().cloned() else {
unreachable!("stage_put_carrying queues exactly one put entry")
};
self.write_image_fields(document)?;
self.stage(key, Some(record.clone()), true)?;
self.flush()?;
Ok(record)
}
pub fn put_with_warnings(&mut self, document: &Value, force: bool) -> Result<PutOutcome, TableError> {
self.put_impl(document, force, true)
}
pub(crate) fn put_from_buffer(&mut self, document: &Value, force: bool) -> Result<HaysonRecord, TableError> {
self.put_impl(document, force, false).map(PutOutcome::into_record)
}
fn put_impl(&mut self, document: &Value, force: bool, live: bool) -> Result<PutOutcome, TableError> {
let record = self.stage_put_impl(document, force, live)?;
self.flush()?;
let warnings = self.eager_link_warnings(&record);
Ok(PutOutcome { record, warnings })
}
pub fn stage_put(&mut self, document: &Value, force: bool) -> Result<HaysonRecord, TableError> {
self.stage_put_impl(document, force, true)
.map(|record| self.without_plain_passwords(record))
}
fn without_plain_passwords(&self, mut record: HaysonRecord) -> HaysonRecord {
for field in &self.password_fields {
if record
.get(field)
.is_some_and(|value| !value.is_null() && !is_password_envelope(value))
{
record.remove(field);
}
}
record
}
fn stage_put_impl(&mut self, document: &Value, force: bool, live: bool) -> Result<HaysonRecord, TableError> {
let accepted = self.accepted_document(document);
let document = accepted.as_ref();
self.assert_partition_stable_against_staged(document)?;
self.refuse_image_collisions(document)?;
let key = self.queue.stage_put(&self.schema, &self.registry, document, force)?;
let Some(record) = self.queue.last_put_record().cloned() else {
unreachable!("stage_put queues exactly one put entry")
};
self.write_image_fields(document)?;
self.stage(key, Some(record.clone()), live)?;
Ok(record)
}
fn stage(&mut self, key: String, record: Option<HaysonRecord>, live: bool) -> io::Result<()> {
let (id, old) = match self.staged.get(&key) {
Some(held) => (held.id, held.old.clone()),
None => {
self.next_op_id += 1;
(self.next_op_id, self.committed(&key)?)
}
};
self.staged.insert(key, StagedWrite { id, record, old, live });
Ok(())
}
fn assert_partition_stable_against_staged(&self, document: &Value) -> Result<(), TableError> {
if self.staged.is_empty() || !is_partitioned(&self.schema) {
return Ok(());
}
let record = validate_record(&self.schema, document, ValidateOptions::default())?;
let key = primary_key_of(&self.schema, &record)?;
let Some(StagedWrite { record: Some(held), .. }) = self.staged.get(&key) else {
return Ok(());
};
let held_part = partition_of(&self.schema, held)?;
let part = partition_of(&self.schema, &record)?;
if held_part == part {
return Ok(());
}
Err(TableError::Partition(HaysonPartitionError::new(
self.schema.table.clone(),
self.schema.partition_by.clone().unwrap_or_default(),
format!(
"key \"{key}\" already lives in partition \"{held_part}\" — delete it before writing it into \"{part}\""
),
)))
}
pub fn flush(&mut self) -> Result<(), TableError> {
if self.queue.depth() == 0 && self.staged.is_empty() {
return Ok(());
}
self.commit()?;
let mut window: Vec<StagedWrite> = self.staged.drain().map(|(_, staged)| staged).collect();
window.sort_by_key(|staged| staged.id);
for staged in window {
if !staged.live || (staged.record.is_none() && staged.old.is_none()) {
continue;
}
let event = match (&staged.old, &staged.record) {
(_, None) => TriggerEvent::Delete,
(Some(_), Some(_)) => TriggerEvent::Update,
(None, Some(_)) => TriggerEvent::Insert,
};
self.fire_same_table_triggers(event, staged.old.as_ref(), staged.record.as_ref())?;
}
Ok(())
}
pub fn patch(&mut self, key: &str, changes: &Value, force: bool) -> Result<HaysonRecord, TableError> {
self.patch_impl(key, changes, force, true).map(PutOutcome::into_record)
}
pub fn patch_with_warnings(&mut self, key: &str, changes: &Value, force: bool) -> Result<PutOutcome, TableError> {
self.patch_impl(key, changes, force, true)
}
pub(crate) fn patch_from_buffer(
&mut self,
key: &str,
changes: &Value,
force: bool,
) -> Result<HaysonRecord, TableError> {
self.patch_impl(key, changes, force, false).map(PutOutcome::into_record)
}
fn patch_impl(&mut self, key: &str, changes: &Value, force: bool, live: bool) -> Result<PutOutcome, TableError> {
let record = self.stage_patch_impl(key, changes, force, live)?;
self.flush()?;
let warnings = self.eager_link_warnings(&record);
Ok(PutOutcome { record, warnings })
}
pub fn stage_patch(&mut self, key: &str, changes: &Value, force: bool) -> Result<HaysonRecord, TableError> {
self.stage_patch_impl(key, changes, force, true)
.map(|record| self.without_plain_passwords(record))
}
fn stage_patch_impl(
&mut self,
key: &str,
changes: &Value,
force: bool,
live: bool,
) -> Result<HaysonRecord, TableError> {
let accepted = self.accepted_document(changes);
let changes = accepted.as_ref();
let (_, merged) = self.merged_patch(key, changes)?;
let claim: Vec<String> = match changes.as_object() {
Some(object) => object.keys().cloned().collect(),
None => Vec::new(),
};
let document = Value::Object(merged.into_iter().collect());
self.assert_partition_stable_against_staged(&document)?;
self.refuse_image_collisions(changes)?;
let key = self
.queue
.stage_put_claiming(&self.schema, &self.registry, &document, &claim, force)?;
let Some(record) = self.queue.last_put_record().cloned() else {
unreachable!("stage_put_claiming queues exactly one put entry")
};
self.write_image_fields(changes)?;
self.stage(key, Some(record.clone()), live)?;
Ok(record)
}
fn merged_patch(&self, key: &str, changes: &Value) -> Result<(HaysonRecord, HaysonRecord), TableError> {
let base = self.get(key)?;
self.merged_patch_over(key, base, changes)
}
fn shadowed_state(&self, shadow: &UnitShadow, key: &str) -> Result<Option<HaysonRecord>, TableError> {
match shadow.get(key) {
Some(held) => Ok(held.clone()),
None => Ok(self.get(key)?),
}
}
fn merged_patch_over(
&self,
key: &str,
base: Option<HaysonRecord>,
changes: &Value,
) -> Result<(HaysonRecord, HaysonRecord), TableError> {
let current = base.ok_or_else(|| {
TableError::NotFound(HaysonNotFoundError {
table: self.schema.table.clone(),
key: key.to_string(),
})
})?;
let partial = validate_record(
&self.schema,
changes,
ValidateOptions {
partial: true,
now: None,
},
)?;
if let Some(new_key) = partial.get(&self.schema.primary_key) {
if value_as_key(new_key) != key {
return Err(TableError::Schema(HaysonSchemaError(format!(
"the primary key of \"{key}\" cannot be changed by patch"
))));
}
}
let mut merged = current.clone();
for (field, value) in &partial {
merged.insert(field.clone(), value.clone());
}
Ok((current, merged))
}
pub fn delete(&mut self, key: &str, force: bool) -> Result<bool, TableError> {
self.delete_impl(key, force, true)
}
pub(crate) fn delete_from_buffer(&mut self, key: &str, force: bool) -> Result<bool, TableError> {
self.delete_impl(key, force, false)
}
fn delete_impl(&mut self, key: &str, force: bool, live: bool) -> Result<bool, TableError> {
let existed = self.stage_delete_impl(key, force, live)?;
self.flush()?;
Ok(existed)
}
pub fn stage_delete(&mut self, key: &str, force: bool) -> Result<bool, TableError> {
self.stage_delete_impl(key, force, true)
}
fn stage_delete_impl(&mut self, key: &str, force: bool, live: bool) -> Result<bool, TableError> {
let old = self.get(key)?;
let part = self.partition_holding(key)?;
if part.is_none() && is_partitioned(&self.schema) {
return Ok(false);
}
self.queue
.stage_delete(key, force, part.as_deref().unwrap_or(UNPARTITIONED))?;
self.stage(key.to_string(), None, live)?;
Ok(old.is_some())
}
fn partition_holding(&self, key: &str) -> Result<Option<String>, TableError> {
match self.staged.get(key) {
Some(StagedWrite {
record: Some(record), ..
}) => Ok(Some(partition_of(&self.schema, record)?)),
Some(_) => Ok(None),
None => Ok(self.registry.part_of(key).map(str::to_string)),
}
}
fn fire_same_table_triggers(
&mut self,
event: TriggerEvent,
old: Option<&HaysonRecord>,
new: Option<&HaysonRecord>,
) -> Result<(), TableError> {
let triggers = self.triggers.clone();
let firing = TriggerFiring { event, old, new };
for trigger in matching(&triggers, &self.schema.table, event) {
if trigger.effect.table != self.schema.table {
continue;
}
let document = effect_document(&trigger.effect, &firing);
self.write_effect(&document)?;
}
Ok(())
}
fn write_effect(&mut self, document: &Value) -> Result<(), TableError> {
self.queue.stage_put(&self.schema, &self.registry, document, true)?;
self.commit()?;
Ok(())
}
fn assert_searchable(&self, spec: &QuerySpec) -> Result<(), TableError> {
if spec.search.is_none() || self.registry.has_fulltext() {
return Ok(());
}
Err(TableError::Schema(HaysonSchemaError(format!(
"table \"{}\" has no field marked \"fulltext\": true — search is not available",
self.schema.table
))))
}
pub fn query(&self, spec: &QuerySpec) -> Result<QueryResult, TableError> {
self.assert_searchable(spec)?;
self.with_record_source(|source| execute_query(&self.registry, source, spec))
}
pub fn query_with_overlay(
&self,
spec: &QuerySpec,
overlay: &dyn QueryOverlay,
delta: &CorpusDelta,
) -> Result<QueryResult, TableError> {
self.assert_searchable(spec)?;
let scores = spec
.search
.as_ref()
.filter(|_| self.registry.has_fulltext())
.map(|search| self.registry.fulltext().search(&search.text, search.mode, Some(delta)));
let access = plan_access_overlaid(&self.registry, spec, scores, overlay);
self.with_record_source(|source| execute_access(&self.registry, source, spec, access, Some(overlay)))
}
pub fn indexed_fields(&self) -> Vec<String> {
self.registry.indexed_fields()
}
pub fn has_fulltext(&self) -> bool {
self.registry.has_fulltext()
}
pub fn tokens_of(&self, record: &HaysonRecord) -> Vec<String> {
self.registry.tokens_of(record)
}
pub fn aggregate(&self, spec: &QuerySpec, aggregate: &AggregateSpec) -> Result<AggregateResult, TableError> {
self.assert_searchable(spec)?;
self.with_record_source(|source| execute_aggregate(&self.registry, source, spec, aggregate))
}
pub fn aggregate_grouped(
&self,
spec: &QuerySpec,
aggregate: &AggregateSpec,
) -> Result<GroupedAggregateResult, TableError> {
self.assert_searchable(spec)?;
self.with_record_source(|source| execute_grouped_aggregate(&self.registry, source, spec, aggregate))
}
fn commit(&mut self) -> Result<Vec<WriteOutcome>, TableError> {
for key in self.queue.queued_keys() {
self.cache.invalidate(key);
}
let schema = &self.schema;
let cipher = self.cipher.as_ref();
let cypher_fields = &self.cypher_fields;
let password_fields = &self.password_fields;
let password_params = &self.password_params;
let seal = |key: &str, record: &HaysonRecord, carried: &[String]| -> Result<HaysonRecord, HaysonCipherError> {
let hashed = hash_stored(schema, record, password_fields, password_params)?;
seal_record(schema, key, &hashed, cipher, cypher_fields, carried)
};
let committed = self
.queue
.commit_sealed(&mut self.logs, &mut self.registry, &mut self.seq, true, &seal);
let outcomes = match committed {
Ok(outcomes) => outcomes,
Err(error) => {
for key in self.queue.take_abandoned_keys() {
self.staged.remove(&key);
}
return Err(error.into());
}
};
self.note_writes(outcomes.len())?;
Ok(outcomes)
}
pub fn current_seq(&self) -> u64 {
self.seq
}
pub fn prepare_txn_ops(&mut self, ops: &[TxnOp]) -> Result<Vec<PreparedTxnEntry>, TableError> {
self.flush()?;
let mut prepared = Vec::with_capacity(ops.len());
let now = chrono::Utc::now().timestamp_millis();
let mut staged_parts: HashMap<String, String> = HashMap::new();
let mut shadow = UnitShadow::new();
for op in ops {
let (key, old, record) = match op {
TxnOp::Delete(key) => (key.clone(), self.shadowed_state(&shadow, key)?, None),
TxnOp::Put(document) => {
let accepted = self.accepted_document(document);
let record = validate_record(&self.schema, accepted.as_ref(), ValidateOptions::default())?;
let key = primary_key_of(&self.schema, &record)?;
assert_unique_in_unit(&self.schema, &self.registry, &key, &record, &shadow)?;
let old = self.shadowed_state(&shadow, &key)?;
(key, old, Some(record))
}
TxnOp::Patch(key, changes) => {
let accepted = self.accepted_document(changes);
let base = self.shadowed_state(&shadow, key)?;
let (old, merged) = self.merged_patch_over(key, base, accepted.as_ref())?;
assert_unique_in_unit(&self.schema, &self.registry, key, &merged, &shadow)?;
(key.clone(), Some(old), Some(merged))
}
};
let part = match &record {
Some(record) => {
let part = partition_of(&self.schema, record)?;
assert_partition_stable(&self.schema, &self.registry, &key, &part)?;
if let Some(held) = staged_parts.get(&key) {
if held != &part {
return Err(TableError::Partition(HaysonPartitionError::new(
self.schema.table.clone(),
self.schema.partition_by.clone().unwrap_or_default(),
format!("key \"{key}\" already lives in partition \"{held}\" — delete it before writing it into \"{part}\""),
)));
}
}
staged_parts.insert(key.clone(), part.clone());
part
}
None => {
let held = staged_parts
.get(&key)
.cloned()
.or_else(|| self.registry.part_of(&key).map(str::to_string));
match held {
Some(part) => part,
None if is_partitioned(&self.schema) => continue,
None => UNPARTITIONED.to_string(),
}
}
};
shadow.insert(key.clone(), record.clone());
self.seq += 1;
let meta = LogMeta {
key: key.clone(),
op: if record.is_some() { LogOp::Put } else { LogOp::Del },
seq: self.seq,
ts: now,
};
let sealed = record
.as_ref()
.map(|rec| {
let hashed = hash_stored(&self.schema, rec, &self.password_fields, &self.password_params)?;
seal_record(
&self.schema,
&key,
&hashed,
self.cipher.as_ref(),
&self.cypher_fields,
&[],
)
})
.transpose()?;
let line = serialize_entry(&meta, sealed.as_ref());
prepared.push(PreparedTxnEntry {
key,
seq: self.seq,
line,
old,
record,
part,
});
}
Ok(prepared)
}
pub fn apply_txn_entries(&mut self, entries: Vec<PreparedTxnEntry>) -> io::Result<()> {
if entries.is_empty() {
return Ok(());
}
let applied = entries.len();
let mut slots: Vec<Option<Slot>> = vec![None; entries.len()];
for part in ordered_parts(&entries) {
let positions: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(_, entry)| entry.part == part)
.map(|(at, _)| at)
.collect();
let lines: Vec<String> = positions.iter().map(|at| entries[*at].line.clone()).collect();
for (at, slot) in positions.into_iter().zip(self.logs.append(&part, &lines)?) {
slots[at] = Some(slot);
}
}
self.logs.sync()?;
for (entry, slot) in entries.into_iter().zip(slots) {
let Some(slot) = slot else { continue };
self.cache.invalidate(&entry.key);
match entry.record {
Some(record) => self
.registry
.apply_put_in(&entry.key, &record, slot, entry.seq, &entry.part),
None => self
.registry
.apply_delete_in(&entry.key, slot.length, entry.seq, &entry.part),
}
}
self.note_writes(applied)
}
pub fn recover_txn_lines(&mut self, entries: &[(u64, String)]) -> Result<usize, TableError> {
let missing: Vec<&(u64, String)> = entries.iter().filter(|(seq, _)| *seq > self.seq).collect();
if missing.is_empty() {
return Ok(0);
}
let applied = missing.len();
let mut pending: Vec<(String, LogEntry, String)> = Vec::with_capacity(applied);
for (_, line) in &missing {
let path = self.logs.path_of(UNPARTITIONED).to_string_lossy().into_owned();
let parsed = parse_entry(line, &path, 0)
.map_err(|error| TableError::Io(io::Error::new(io::ErrorKind::InvalidData, error)))?;
let part = match (&parsed.meta.op, &parsed.data) {
(LogOp::Put, Some(record)) => partition_of(&self.schema, record)?,
(LogOp::Put, None) => {
return Err(TableError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!("journalled put for \"{}\" carries no record", parsed.meta.key),
)))
}
(LogOp::Del, _) => self
.registry
.part_of(&parsed.meta.key)
.unwrap_or(UNPARTITIONED)
.to_string(),
};
pending.push((part, parsed, line.clone()));
}
for (part, parsed, line) in pending {
let slot = self.logs.append(&part, std::slice::from_ref(&line))?[0];
self.cache.invalidate(&parsed.meta.key);
match parsed.data {
Some(record) => self
.registry
.apply_put_in(&parsed.meta.key, &record, slot, parsed.meta.seq, &part),
None => self
.registry
.apply_delete_in(&parsed.meta.key, slot.length, parsed.meta.seq, &part),
}
self.seq = self.seq.max(parsed.meta.seq);
}
self.logs.sync()?;
self.snapshot_current = false;
self.snapshot()?;
Ok(applied)
}
}
fn ordered_parts(entries: &[PreparedTxnEntry]) -> Vec<String> {
let mut parts: Vec<String> = Vec::new();
for entry in entries {
if !parts.iter().any(|part| part == &entry.part) {
parts.push(entry.part.clone());
}
}
parts
}
fn partition_dir_of(log_path: &Path, table: &str) -> PathBuf {
match log_path.parent() {
Some(parent) => parent.join(table),
None => PathBuf::from(table),
}
}
fn open_logs(schema: &TableSchema, path: &Path, readonly: bool) -> io::Result<LogSet> {
if !is_partitioned(schema) {
return LogSet::flat(path, readonly);
}
let partition_dir = partition_dir_of(path, &schema.table);
if readonly {
if partition_names_in(&partition_dir).is_empty() && path.exists() {
return LogSet::flat(path, true);
}
return LogSet::partitioned(partition_dir, true);
}
ensure_partitioned(path, &partition_dir, schema)?;
LogSet::partitioned(partition_dir, readonly)
}
pub enum TxnOp {
Put(Value),
Patch(String, Value),
Delete(String),
}
pub struct PreparedTxnEntry {
key: String,
seq: u64,
line: String,
old: Option<HaysonRecord>,
record: Option<HaysonRecord>,
part: String,
}
impl PreparedTxnEntry {
pub fn seq(&self) -> u64 {
self.seq
}
pub fn line(&self) -> &str {
&self.line
}
pub fn old(&self) -> Option<&HaysonRecord> {
self.old.as_ref()
}
pub fn record(&self) -> Option<&HaysonRecord> {
self.record.as_ref()
}
pub fn upsert_event(&self) -> crate::catalog::TriggerEvent {
if self.old.is_some() {
crate::catalog::TriggerEvent::Update
} else {
crate::catalog::TriggerEvent::Insert
}
}
}
fn hash_stored(
schema: &TableSchema,
record: &HaysonRecord,
password_fields: &[String],
params: &PasswordHashParams,
) -> Result<HaysonRecord, HaysonCipherError> {
if password_fields.is_empty() {
return Ok(record.clone());
}
hash_record(record, password_fields, params)
.map_err(|error| HaysonCipherError::new(&schema.table, "password".to_string(), error.0))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use tempfile::tempdir;
use super::*;
use crate::query::builder::QueryBuilder;
use crate::query::predicates::ComparisonOp;
use crate::schema::parse_schema;
fn users_schema() -> TableSchema {
parse_schema(&json!({
"schemaVersion": 1,
"table": "users",
"primaryKey": "id",
"fields": {
"id": { "type": "string" },
"name": { "type": "string", "required": true },
"age": { "type": "int", "index": "sorted" },
"email": { "type": "string", "unique": true },
},
}))
.unwrap()
}
fn open_table() -> (tempfile::TempDir, Table) {
let dir = tempdir().unwrap();
let path = dir.path().join("users.jsonl");
let table = Table::open(users_schema(), &path).unwrap();
(dir, table)
}
#[test]
fn a_table_set_to_sync_never_flushes_its_writes_without_a_single_fsync() {
let (_dir, mut table) = open_table();
table.set_sync(CatalogSyncMode::Never);
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
table
.put(&json!({ "id": "u2", "name": "Piotr", "age": 41 }), false)
.unwrap();
table.flush().unwrap();
assert_eq!(table.sync_mode(), CatalogSyncMode::Never);
assert_eq!(table.logs.log_of(UNPARTITIONED).unwrap().fsyncs(), 0);
assert_eq!(table.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
assert_eq!(table.get("u2").unwrap().unwrap().get("name"), Some(&json!("Piotr")));
}
#[test]
fn a_table_nobody_configured_still_fsyncs_every_flush() {
let (_dir, mut table) = open_table();
assert_eq!(table.sync_mode(), CatalogSyncMode::Commit);
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
table.flush().unwrap();
assert_eq!(table.logs.log_of(UNPARTITIONED).unwrap().fsyncs(), 1);
}
#[test]
fn history_hands_back_every_version_of_one_key_oldest_first() {
let (dir, mut table) = open_table();
table
.put(
&json!({ "id": "u1", "name": "Ania", "age": 30, "email": "a@example.test" }),
false,
)
.unwrap();
table
.put(
&json!({ "id": "u2", "name": "Piotr", "age": 41, "email": "p@example.test" }),
false,
)
.unwrap();
table.patch("u1", &json!({ "age": 31 }), false).unwrap();
table.delete("u1", false).unwrap();
table
.put(
&json!({ "id": "u1", "name": "Ania", "age": 32, "email": "a@example.test" }),
false,
)
.unwrap();
let history = table.history("u1").unwrap();
assert_eq!(history.scanned, vec![dir.path().join("users.jsonl")]);
let shape: Vec<(u64, LogOp, Option<&Value>)> = history
.entries
.iter()
.map(|entry| (entry.seq, entry.op, entry.document.as_ref().and_then(|d| d.get("age"))))
.collect();
assert_eq!(
shape,
vec![
(1, LogOp::Put, Some(&json!(30))),
(3, LogOp::Put, Some(&json!(31))),
(4, LogOp::Del, None),
(5, LogOp::Put, Some(&json!(32))),
]
);
assert!(history.entries.iter().all(|entry| entry.partition == UNPARTITIONED));
assert!(history.entries.iter().all(|entry| entry.ts > 0));
assert_eq!(table.history("u2").unwrap().entries.len(), 1);
assert!(table.history("nobody").unwrap().entries.is_empty());
}
#[test]
fn history_reads_the_line_the_offsets_it_reports_point_at() {
let (_dir, mut table) = open_table();
table
.put(
&json!({ "id": "u1", "name": "Ania", "age": 30, "email": "a@example.test" }),
false,
)
.unwrap();
table.patch("u1", &json!({ "age": 31 }), false).unwrap();
let history = table.history("u1").unwrap();
let log = table.logs.log_of(UNPARTITIONED).unwrap();
for entry in &history.entries {
let slot = Slot {
offset: entry.offset,
length: entry.length,
};
let line: Value = serde_json::from_str(&log.read_slice(&slot).unwrap()).unwrap();
assert_eq!(line.get("_seq"), Some(&json!(entry.seq)));
}
}
#[test]
fn history_does_not_see_a_write_that_is_still_staged() {
let (_dir, mut table) = open_table();
table
.stage_put(
&json!({ "id": "u1", "name": "Ania", "age": 30, "email": "a@example.test" }),
false,
)
.unwrap();
assert!(table.history("u1").unwrap().entries.is_empty());
table.flush().unwrap();
assert_eq!(table.history("u1").unwrap().entries.len(), 1);
}
#[test]
fn a_table_nobody_configured_refuses_a_write_naming_an_undeclared_field() {
let (_dir, mut table) = open_table();
assert_eq!(table.validation(), CatalogValidation::Strict);
let error = table
.put(&json!({ "id": "u1", "name": "Ania", "nickname": "Anka" }), false)
.unwrap_err();
assert!(error.to_string().contains("nickname"), "{error}");
assert!(error.to_string().contains("is not declared in the schema"), "{error}");
assert_eq!(table.get("u1").unwrap(), None);
}
#[test]
fn a_warn_table_takes_the_write_drops_the_undeclared_field_and_says_which() {
let dir = tempdir().unwrap();
let path = dir.path().join("users.jsonl");
let mut table = Table::open(users_schema(), &path).unwrap();
table.set_validation(CatalogValidation::Warn);
let document = json!({ "id": "u1", "name": "Ania", "age": 30, "nickname": "Anka" });
let record = table.put(&document, false).unwrap();
assert_eq!(record.get("name"), Some(&json!("Ania")));
assert_eq!(record.get("nickname"), None);
assert_eq!(table.get("u1").unwrap().unwrap().get("nickname"), None);
assert_eq!(
table.dropped_field_warnings(&document),
vec!["field \"nickname\": is not declared in the schema, dropped from the write".to_string()]
);
let written = fs::read_to_string(&path).unwrap();
assert!(!written.contains("nickname"), "{written}");
assert!(!written.contains("Anka"), "{written}");
}
#[test]
fn a_warn_table_still_refuses_a_bad_type_a_missing_required_field_and_a_reserved_key() {
let (_dir, mut table) = open_table();
table.set_validation(CatalogValidation::Warn);
let bad_type = table
.put(&json!({ "id": "u1", "name": "Ania", "age": "thirty" }), false)
.unwrap_err();
let missing = table.put(&json!({ "id": "u2" }), false).unwrap_err();
let reserved = table
.put(&json!({ "id": "u3", "name": "Ola", "_seq": 7 }), false)
.unwrap_err();
assert!(bad_type.to_string().contains("expected an integer"), "{bad_type}");
assert!(missing.to_string().contains("is required"), "{missing}");
assert!(reserved.to_string().contains("reserved"), "{reserved}");
}
#[test]
fn a_warn_patch_merges_the_declared_changes_and_drops_the_undeclared_one() {
let (_dir, mut table) = open_table();
table.set_validation(CatalogValidation::Warn);
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
let record = table
.patch("u1", &json!({ "age": 31, "nickname": "Anka" }), false)
.unwrap();
assert_eq!(record.get("age"), Some(&json!(31)));
assert_eq!(record.get("nickname"), None);
assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(31)));
}
#[test]
fn a_warn_table_prepares_a_unit_of_work_line_without_the_undeclared_field() {
let (_dir, mut table) = open_table();
table.set_validation(CatalogValidation::Warn);
let prepared = table
.prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Ania", "nickname": "Anka" }))])
.unwrap();
assert_eq!(prepared.len(), 1);
assert!(!prepared[0].line.contains("nickname"), "{}", prepared[0].line);
assert_eq!(prepared[0].record.as_ref().unwrap().get("nickname"), None);
}
#[test]
fn dropped_field_warnings_answer_nothing_on_a_strict_table_or_a_document_with_no_stray_field() {
let (_dir, mut table) = open_table();
let document = json!({ "id": "u1", "name": "Ania", "nickname": "Anka" });
assert!(table.dropped_field_warnings(&document).is_empty());
table.set_validation(CatalogValidation::Warn);
assert!(table
.dropped_field_warnings(&json!({ "id": "u1", "name": "Ania" }))
.is_empty());
assert_eq!(table.dropped_field_warnings(&document).len(), 1);
}
fn deprecating(age: &str) -> BTreeMap<String, String> {
BTreeMap::from([("age".to_string(), age.to_string())])
}
#[test]
fn deprecated_field_warnings_name_the_marked_field_the_document_touches_and_quote_its_note() {
let (_dir, mut table) = open_table();
table.set_deprecated_fields(deprecating("use \"bornAt\" instead"));
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
assert_eq!(
table.deprecated_field_warnings(&json!({ "id": "u1", "name": "Ania", "age": 30 })),
vec!["field \"age\": is deprecated — use \"bornAt\" instead".to_string()]
);
assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(30)));
}
#[test]
fn deprecated_field_warnings_stay_quiet_for_a_document_touching_no_marked_field() {
let (_dir, mut table) = open_table();
table.set_deprecated_fields(deprecating("use \"bornAt\" instead"));
assert!(table
.deprecated_field_warnings(&json!({ "id": "u1", "name": "Ania" }))
.is_empty());
assert!(table.deprecated_field_warnings(&json!({ "name": "Ola" })).is_empty());
}
#[test]
fn a_field_handed_over_with_an_empty_note_warns_with_the_field_alone() {
let (_dir, mut table) = open_table();
table.set_deprecated_fields(deprecating(""));
assert_eq!(
table.deprecated_field_warnings(&json!({ "id": "u1", "age": 30 })),
vec!["field \"age\": is deprecated".to_string()]
);
}
#[test]
fn deprecated_field_warnings_answer_the_same_in_either_validation_mode_and_on_every_write() {
let (_dir, mut table) = open_table();
table.set_deprecated_fields(deprecating("use \"bornAt\" instead"));
let document = json!({ "id": "u1", "name": "Ania", "age": 30 });
let expected = vec!["field \"age\": is deprecated — use \"bornAt\" instead".to_string()];
assert_eq!(table.validation(), CatalogValidation::Strict);
table.put(&document, false).unwrap();
assert_eq!(table.deprecated_field_warnings(&document), expected);
table.set_validation(CatalogValidation::Warn);
table.put(&document, false).unwrap();
assert_eq!(table.deprecated_field_warnings(&document), expected);
assert!(table.dropped_field_warnings(&document).is_empty());
}
#[test]
fn a_table_nobody_marked_answers_no_deprecation_warning_at_all() {
let (_dir, table) = open_table();
assert!(table.deprecated_fields().is_empty());
assert!(table
.deprecated_field_warnings(&json!({ "id": "u1", "name": "Ania", "age": 30 }))
.is_empty());
}
#[test]
fn what_a_sync_never_table_wrote_is_still_there_after_reopening_it() {
let dir = tempdir().unwrap();
let path = dir.path().join("users.jsonl");
{
let mut table = Table::open(users_schema(), &path).unwrap();
table.set_sync(CatalogSyncMode::Never);
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
table.flush().unwrap();
}
let reopened = Table::open(users_schema(), &path).unwrap();
assert_eq!(reopened.get("u1").unwrap().unwrap().get("age"), Some(&json!(30)));
}
#[test]
fn put_then_get_round_trips_the_record() {
let (_dir, mut table) = open_table();
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
let fetched = table.get("u1").unwrap().unwrap();
assert_eq!(fetched.get("name"), Some(&json!("Ania")));
assert_eq!(fetched.get("age"), Some(&json!(30)));
}
#[test]
fn get_of_an_unknown_key_is_none() {
let (_dir, table) = open_table();
assert_eq!(table.get("missing").unwrap(), None);
}
#[test]
fn put_delete_get_leaves_nothing_behind() {
let (_dir, mut table) = open_table();
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
let existed = table.delete("u1", false).unwrap();
assert!(existed);
assert_eq!(table.get("u1").unwrap(), None);
}
#[test]
fn delete_of_an_unknown_key_reports_it_did_not_exist() {
let (_dir, mut table) = open_table();
let existed = table.delete("ghost", false).unwrap();
assert!(!existed);
}
#[test]
fn patch_merges_only_the_given_fields() {
let (_dir, mut table) = open_table();
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
let patched = table.patch("u1", &json!({ "age": 31 }), false).unwrap();
assert_eq!(patched.get("name"), Some(&json!("Ania")));
assert_eq!(patched.get("age"), Some(&json!(31)));
let fetched = table.get("u1").unwrap().unwrap();
assert_eq!(fetched, patched);
}
fn self_audit_trigger() -> TriggerEntry {
TriggerEntry {
name: "users_insert_self_audit".into(),
table: "users".into(),
event: TriggerEvent::Insert,
effect: crate::catalog::TriggerEffect {
table: "users".into(),
fields: vec![
crate::catalog::TriggerFieldMapping {
target: "id".into(),
source: crate::catalog::TriggerValueSource::Constant(json!("audit-u1")),
},
crate::catalog::TriggerFieldMapping {
target: "name".into(),
source: crate::catalog::TriggerValueSource::NewField("name".into()),
},
],
},
}
}
#[test]
fn a_live_put_fires_a_same_table_trigger_right_after_the_write_it_fired_on() {
let (_dir, mut table) = open_table();
table.set_triggers(vec![self_audit_trigger()]);
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
assert!(table.get("u1").unwrap().is_some());
let effect = table.get("audit-u1").unwrap().unwrap();
assert_eq!(effect.get("name"), Some(&json!("Ania")));
}
#[test]
fn a_trigger_only_fires_for_the_event_it_declares() {
let (_dir, mut table) = open_table();
table.set_triggers(vec![self_audit_trigger()]);
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
table.patch("u1", &json!({ "name": "Anna" }), false).unwrap();
assert_eq!(
table.get("audit-u1").unwrap().unwrap().get("name"),
Some(&json!("Ania"))
);
}
#[test]
fn patch_of_an_unknown_key_is_an_error() {
let (_dir, mut table) = open_table();
let err = table.patch("ghost", &json!({ "age": 1 }), false).unwrap_err();
assert!(matches!(err, TableError::NotFound(_)));
}
#[test]
fn patch_cannot_change_the_primary_key() {
let (_dir, mut table) = open_table();
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
let err = table.patch("u1", &json!({ "id": "u2" }), false).unwrap_err();
assert!(matches!(err, TableError::Schema(_)));
}
#[test]
fn repeated_puts_on_the_same_key_leave_only_the_latest_version() {
let (_dir, mut table) = open_table();
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
table.put(&json!({ "id": "u1", "name": "Zosia" }), false).unwrap();
table.put(&json!({ "id": "u1", "name": "Kasia" }), false).unwrap();
let fetched = table.get("u1").unwrap().unwrap();
assert_eq!(fetched.get("name"), Some(&json!("Kasia")));
}
#[test]
fn a_search_against_a_table_with_no_fulltext_field_is_refused_rather_than_answered_empty() {
let (_dir, mut table) = open_table();
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
let spec = QueryBuilder::new()
.search("anything", crate::registry::fulltext_index::SearchMode::All)
.spec();
let refusal = table.query(&spec).unwrap_err();
assert!(
matches!(&refusal, TableError::Schema(_)),
"a table with no fulltext field is a schema refusal, not an io failure: {refusal:?}"
);
assert!(
refusal.to_string().contains("has no field marked \"fulltext\""),
"the message is the one the TS package raised: {refusal}"
);
}
#[test]
fn query_delegates_to_the_planner_and_matches_it_directly() {
let (_dir, mut table) = open_table();
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
table
.put(&json!({ "id": "u2", "name": "Piotr", "age": 24 }), false)
.unwrap();
let spec = QueryBuilder::new().r#where("age", ComparisonOp::Gt, json!(25)).spec();
let via_table = table.query(&spec).unwrap();
let mut source = LogSetRecordSource::new(&table.registry, &table.logs);
let direct = execute_query(&table.registry, &mut source, &spec).unwrap();
assert_eq!(via_table, direct);
assert_eq!(via_table.keys, vec!["u1".to_string()]);
}
#[test]
fn aggregate_delegates_to_the_planner_and_matches_it_directly() {
let (_dir, mut table) = open_table();
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
table
.put(&json!({ "id": "u2", "name": "Piotr", "age": 24 }), false)
.unwrap();
let (spec, aggregate) = QueryBuilder::new().count().sum("age").avg("age").build_aggregate();
let via_table = table.aggregate(&spec, &aggregate).unwrap();
let mut source = LogSetRecordSource::new(&table.registry, &table.logs);
let direct =
crate::query::aggregate::execute_aggregate(&table.registry, &mut source, &spec, &aggregate).unwrap();
assert_eq!(via_table, direct);
assert_eq!(via_table.count, 2);
assert_eq!(via_table.sum["age"], 54.0);
assert_eq!(via_table.avg["age"], Some(27.0));
}
fn open_table_with_snapshot_every(snapshot_every: u64) -> (tempfile::TempDir, Table, std::path::PathBuf) {
let dir = tempdir().unwrap();
let log_path = dir.path().join("users.jsonl");
let snapshot_path = dir.path().join("users.idx");
let (table, _) =
Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", snapshot_every)
.unwrap();
(dir, table, snapshot_path)
}
#[test]
fn writes_a_snapshot_once_the_operation_threshold_is_crossed() {
let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(3);
assert!(!snapshot_path.exists());
for n in 0..3 {
table
.put(
&json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
false,
)
.unwrap();
}
let snapshot = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
assert_eq!(snapshot.header.watermark, table.logs.total_size());
assert_eq!(snapshot.entries.len(), 3);
assert!(!table.needs_snapshot());
}
#[test]
fn a_snapshot_written_mid_stream_does_not_disturb_later_reads_and_writes() {
let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(2);
table
.put(
&json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
false,
)
.unwrap();
table
.put(
&json!({ "id": "u2", "name": "Piotr", "age": 24, "email": "u2@x.com" }),
false,
)
.unwrap();
assert!(snapshot_path.exists());
table
.put(
&json!({ "id": "u3", "name": "Kasia", "age": 40, "email": "u3@x.com" }),
false,
)
.unwrap();
let existed = table.delete("u1", false).unwrap();
assert!(existed);
assert_eq!(table.get("u1").unwrap(), None);
assert_eq!(table.get("u3").unwrap().unwrap().get("name"), Some(&json!("Kasia")));
}
#[test]
fn compaction_replaces_the_snapshot_with_one_that_pairs_with_the_rewritten_log() {
let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(0);
for n in 0..4 {
table
.put(
&json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
false,
)
.unwrap();
}
table.delete("u0", false).unwrap();
table.delete("u1", false).unwrap();
table.snapshot().unwrap();
let stale = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
assert!(
stale.header.dead_bytes > 0,
"the seed really did leave dead bytes behind"
);
let result = table.compact().unwrap();
assert!(result.reclaimed_bytes > 0, "the compaction really did shrink the log");
let fresh = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
assert_eq!(fresh.header.watermark, table.logs.total_size());
assert_eq!(
fresh.header.watermark,
fs::metadata(table.logs.log_of("").unwrap().path()).unwrap().len()
);
assert!(fresh.header.watermark < stale.header.watermark);
assert_eq!(fresh.header.dead_bytes, 0);
assert_eq!(fresh.entries.len(), 2);
assert!(!table.needs_snapshot());
}
#[test]
fn the_generation_counts_compactions_and_survives_a_restart_through_the_snapshot_header() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("users.jsonl");
let snapshot_path = dir.path().join("users.idx");
{
let (mut table, _) =
Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
assert_eq!(
table.stats().gen,
0,
"a table opened over a log with no snapshot starts at zero"
);
for n in 0..3 {
table
.put(
&json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
false,
)
.unwrap();
}
table.delete("u0", false).unwrap();
table.compact().unwrap();
assert_eq!(table.stats().gen, 1);
table.compact().unwrap();
assert_eq!(table.stats().gen, 2);
assert_eq!(
crate::checkpoint::read_snapshot(&snapshot_path)
.unwrap()
.unwrap()
.header
.gen,
2,
"the snapshot carries the generation it describes"
);
}
let (mut reopened, outcome) =
Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
assert_eq!(outcome, RestoreOutcome::Restored);
assert_eq!(
reopened.stats().gen,
2,
"the restart carries the generation over instead of starting again"
);
reopened.compact().unwrap();
assert_eq!(reopened.stats().gen, 3);
assert_eq!(
crate::checkpoint::read_snapshot(&snapshot_path)
.unwrap()
.unwrap()
.header
.gen,
3
);
}
#[test]
fn a_snapshot_written_before_the_header_carried_a_generation_still_restores_and_starts_at_zero() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("users.jsonl");
let snapshot_path = dir.path().join("users.idx");
{
let (mut table, _) =
Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
for n in 0..3 {
table
.put(
&json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
false,
)
.unwrap();
}
table.compact().unwrap();
assert_eq!(table.stats().gen, 1);
}
let written = fs::read_to_string(&snapshot_path).unwrap();
let (header_line, entry_lines) = written.split_once('\n').unwrap();
let mut header: serde_json::Map<String, Value> = serde_json::from_str(header_line).unwrap();
assert!(
header.remove("gen").is_some(),
"the header this engine writes carries the field to remove"
);
fs::write(
&snapshot_path,
format!("{}\n{entry_lines}", serde_json::to_string(&header).unwrap()),
)
.unwrap();
let (mut reopened, outcome) =
Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
assert_eq!(
outcome,
RestoreOutcome::Restored,
"an old snapshot is still a usable cache"
);
assert_eq!(reopened.len(), 3);
assert_eq!(reopened.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
assert_eq!(reopened.stats().gen, 0);
reopened.compact().unwrap();
assert_eq!(
reopened.stats().gen,
1,
"counting resumes from the default, and lands in the rewritten header"
);
assert_eq!(
crate::checkpoint::read_snapshot(&snapshot_path)
.unwrap()
.unwrap()
.header
.gen,
1
);
}
#[test]
fn reads_agree_before_and_after_a_restart_that_follows_a_compaction() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("users.jsonl");
let snapshot_path = dir.path().join("users.idx");
let spec = QueryBuilder::new()
.r#where("name", ComparisonOp::Eq, json!("Ania"))
.spec();
let (before, after_compaction) = {
let (mut table, _) =
Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
for n in 0..5 {
table
.put(
&json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
false,
)
.unwrap();
}
table.patch("u2", &json!({ "age": 31 }), false).unwrap();
table.delete("u4", false).unwrap();
table.snapshot().unwrap();
let before = table.query(&spec).unwrap().keys;
table.compact().unwrap();
let after = table.query(&spec).unwrap().keys;
(before, after)
};
let (reopened, outcome) =
Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
assert_eq!(
outcome,
RestoreOutcome::Restored,
"the snapshot compaction wrote pairs with the new log"
);
assert_eq!(
after_compaction, before,
"compaction changed neither the set nor the order of live keys"
);
assert_eq!(reopened.query(&spec).unwrap().keys, before);
assert_eq!(reopened.len(), 4);
assert_eq!(reopened.get("u2").unwrap().unwrap().get("age"), Some(&json!(31)));
assert_eq!(reopened.get("u4").unwrap(), None);
}
#[test]
fn zero_disables_periodic_snapshotting() {
let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(0);
for n in 0..10 {
table
.put(
&json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
false,
)
.unwrap();
}
assert!(!snapshot_path.exists());
assert!(table.needs_snapshot());
}
#[test]
fn a_table_opened_without_a_snapshot_path_never_writes_one() {
let (_dir, mut table) = open_table();
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
table.snapshot().unwrap();
assert!(!table.needs_snapshot());
}
fn two_handles() -> (tempfile::TempDir, Table, Table) {
let dir = tempdir().unwrap();
let path = dir.path().join("users.jsonl");
let writer = Table::open(users_schema(), &path).unwrap();
let follower = Table::open(users_schema(), &path).unwrap();
(dir, writer, follower)
}
#[test]
fn refresh_on_a_log_nobody_touched_reports_no_change_and_leaves_the_registry_alone() {
let (_dir, mut table) = open_table();
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
let first = table.refresh().unwrap();
let second = table.refresh().unwrap();
assert_eq!(first, RefreshOutcome::Unchanged);
assert_eq!(
second,
RefreshOutcome::Unchanged,
"a handle is not behind its own writes"
);
assert!(!first.changed());
assert_eq!(table.len(), 1);
assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(30)));
}
#[test]
fn refresh_applies_the_records_another_handle_appended_to_the_same_log() {
let (_dir, mut writer, mut follower) = two_handles();
writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
assert_eq!(
follower.get("u1").unwrap(),
None,
"a handle sees nothing until it looks again"
);
let outcome = follower.refresh().unwrap();
assert_eq!(outcome, RefreshOutcome::CaughtUp { entries: 1 });
assert_eq!(follower.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
assert_eq!(follower.len(), 1);
assert_eq!(follower.stats().seq, writer.stats().seq);
}
#[test]
fn refresh_applies_a_foreign_delete_as_well_as_a_foreign_write() {
let (_dir, mut writer, mut follower) = two_handles();
writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
writer.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
follower.refresh().unwrap();
writer.delete("u1", false).unwrap();
assert_eq!(follower.refresh().unwrap(), RefreshOutcome::CaughtUp { entries: 1 });
assert!(!follower.has("u1"));
assert!(follower.has("u2"));
assert_eq!(follower.len(), 1);
}
#[test]
fn refresh_rebuilds_after_another_handle_compacted_the_log_under_it() {
let (_dir, mut writer, mut follower) = two_handles();
writer
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
writer.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
writer.patch("u1", &json!({ "age": 31 }), false).unwrap();
writer.delete("u2", false).unwrap();
follower.refresh().unwrap();
let before = follower.stats().log_bytes;
writer.compact().unwrap();
let outcome = follower.refresh().unwrap();
assert_eq!(outcome, RefreshOutcome::Reloaded);
assert!(
follower.stats().log_bytes < before,
"the compaction really did shrink the file"
);
assert_eq!(follower.len(), 1);
assert_eq!(follower.get("u1").unwrap().unwrap().get("age"), Some(&json!(31)));
assert_eq!(follower.get("u2").unwrap(), None);
}
#[cfg(unix)]
#[test]
fn refresh_catches_a_foreign_compaction_even_when_the_log_ends_up_longer_than_before() {
let (_dir, mut writer, mut follower) = two_handles();
for round in 0..6 {
writer
.put(&json!({ "id": "u1", "name": format!("round {round}") }), false)
.unwrap();
}
follower.refresh().unwrap();
let watermark = follower.stats().log_bytes;
writer.compact().unwrap();
for id in ["u2", "u3", "u4", "u5", "u6"] {
writer
.put(&json!({ "id": id, "name": "after the compaction" }), false)
.unwrap();
}
assert!(
writer.stats().log_bytes > watermark,
"the log grew past where the follower stood"
);
let outcome = follower.refresh().unwrap();
assert_eq!(outcome, RefreshOutcome::Reloaded);
assert_eq!(follower.len(), 6);
assert_eq!(
follower.get("u1").unwrap().unwrap().get("name"),
Some(&json!("round 5"))
);
assert_eq!(
follower.get("u6").unwrap().unwrap().get("name"),
Some(&json!("after the compaction"))
);
}
#[test]
fn a_reload_keeps_serving_queries_from_the_declared_indexes_it_was_built_with() {
let dir = tempdir().unwrap();
let path = dir.path().join("users.jsonl");
let declared = vec![ExpandedIndex {
name: "users_name_email".into(),
fields: vec!["name".into(), "email".into()],
kind: crate::catalog::CatalogIndexKind::Exact,
unique: false,
where_: None,
expire_after_seconds: None,
}];
let mut writer = Table::open_with_declared_indexes(users_schema(), &path, &declared).unwrap();
let mut follower = Table::open_with_declared_indexes(users_schema(), &path, &declared).unwrap();
writer
.put(&json!({ "id": "u1", "name": "Ania", "email": "a@x.pl" }), false)
.unwrap();
follower.refresh().unwrap();
writer.compact().unwrap();
assert_eq!(follower.refresh().unwrap(), RefreshOutcome::Reloaded);
let spec = QueryBuilder::new()
.r#where("name", ComparisonOp::Eq, json!("Ania"))
.r#where("email", ComparisonOp::Eq, json!("a@x.pl"))
.spec();
let found = follower.query(&spec).unwrap();
assert_eq!(found.keys, vec!["u1".to_string()]);
assert_eq!(
found.plan.index.as_deref(),
Some("users_name_email"),
"a rebuilt registry kept the declared index"
);
}
#[cfg(unix)]
#[test]
fn a_write_after_the_log_was_swapped_out_under_the_table_is_refused_rather_than_lost() {
let (dir, mut table) = open_table();
let path = dir.path().join("users.jsonl");
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
let swapped = dir.path().join("users.jsonl.swap");
std::fs::copy(&path, &swapped).unwrap();
std::fs::rename(&swapped, &path).unwrap();
let error = table.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap_err();
let TableError::Io(io) = &error else {
panic!("expected an io refusal, got {error}");
};
let replaced = io
.get_ref()
.and_then(|inner| inner.downcast_ref::<crate::errors::HaysonLogReplacedError>())
.expect("the refusal names the swapped log");
assert_eq!(replaced.path, path.display().to_string());
let reopened = Table::open(users_schema(), &path).unwrap();
assert_eq!(reopened.len(), 1, "nothing landed in the orphaned inode");
assert_eq!(reopened.get("u2").unwrap(), None);
}
#[cfg(unix)]
fn swap_older_version_in(path: &Path) -> Vec<u8> {
let current = std::fs::read_to_string(path).unwrap();
let older = format!("{}\n", current.lines().next().unwrap());
let swapped = path.with_extension("swap");
std::fs::write(&swapped, &older).unwrap();
std::fs::rename(&swapped, path).unwrap();
older.into_bytes()
}
fn log_replaced_of(error: &io::Error) -> Option<&crate::errors::HaysonLogReplacedError> {
error.get_ref()?.downcast_ref::<crate::errors::HaysonLogReplacedError>()
}
#[cfg(unix)]
#[test]
fn a_compaction_after_the_log_was_swapped_out_is_refused_and_leaves_the_new_file_alone() {
let (dir, mut table) = open_table();
let path = dir.path().join("users.jsonl");
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
table.put(&json!({ "id": "u1", "name": "Anna" }), false).unwrap();
let planted = swap_older_version_in(&path);
let Err(error) = table.compact() else {
panic!("the compaction ran over a swapped log");
};
assert!(log_replaced_of(&error).is_some(), "{error}");
assert_eq!(
std::fs::read(&path).unwrap(),
planted,
"the file at the path was rewritten"
);
}
#[cfg(unix)]
#[test]
fn a_refused_write_leaves_no_ghost_in_the_staging_window() {
let (dir, mut table) = open_table();
let path = dir.path().join("users.jsonl");
table.put(&json!({ "id": "u0", "name": "Ola" }), false).unwrap();
table.set_triggers(vec![self_audit_trigger()]);
let planted = std::fs::read(&path).unwrap();
let swapped = path.with_extension("swap");
std::fs::copy(&path, &swapped).unwrap();
std::fs::rename(&swapped, &path).unwrap();
let document = json!({ "id": "u1", "name": "Ania" });
let TableError::Io(first) = table.put(&document, false).unwrap_err() else {
panic!("expected an io refusal");
};
assert!(log_replaced_of(&first).is_some(), "{first}");
assert_eq!(table.get("u1").unwrap(), None, "the refused write is still readable");
assert!(table.pending("u1").is_empty(), "the refused write kept its claim");
assert!(!table.has_staged_writes());
let second = table.put(&document, false).unwrap_err();
let TableError::Io(second) = second else {
panic!("the retry hit the ghost of the refused write: {second}");
};
assert!(log_replaced_of(&second).is_some(), "{second}");
assert_eq!(table.refresh().unwrap(), RefreshOutcome::Reloaded);
table.flush().unwrap();
assert_eq!(
table.get("audit-u1").unwrap(),
None,
"a trigger fired for a write that never landed"
);
assert_eq!(
std::fs::read(&path).unwrap(),
planted,
"something landed in the swapped-in file"
);
}
#[test]
fn refresh_opens_a_partition_file_another_handle_created_and_reads_it_whole() {
let dir = tempdir().unwrap();
let path = dir.path().join("notes.jsonl");
let schema = parse_schema(&json!({
"schemaVersion": 1,
"table": "notes",
"primaryKey": "id",
"partitionBy": "kind",
"fields": { "id": { "type": "string" }, "kind": { "type": "string", "required": true } },
}))
.unwrap();
let mut writer = Table::open(schema.clone(), &path).unwrap();
let mut follower = Table::open(schema, &path).unwrap();
writer.put(&json!({ "id": "a1", "kind": "adr" }), false).unwrap();
writer.put(&json!({ "id": "n1", "kind": "note" }), false).unwrap();
let outcome = follower.refresh().unwrap();
assert_eq!(outcome, RefreshOutcome::CaughtUp { entries: 2 });
let mut keys: Vec<&String> = follower.keys().collect();
keys.sort();
assert_eq!(keys, vec!["a1", "n1"]);
}
#[test]
fn follow_is_due_only_where_follow_if_due_would_reach_for_the_files() {
let (_dir, mut writer, mut follower) = two_handles();
assert!(!follower.follow_is_due(), "no policy is the writable handle's default");
assert_eq!(follower.follow_if_due().unwrap(), None);
follower.set_follow(Some(FollowPolicy {
interval: Duration::ZERO,
}));
assert!(follower.follow_is_due(), "a zero interval is due on every read");
follower.set_follow(Some(FollowPolicy {
interval: Duration::from_secs(60),
}));
assert!(
follower.follow_is_due(),
"and a handle that never looked is due whatever it is"
);
writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
assert_eq!(
follower.follow_if_due().unwrap(),
Some(RefreshOutcome::CaughtUp { entries: 1 })
);
assert!(
!follower.follow_is_due(),
"the interval has not elapsed since that catch-up"
);
writer.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
assert_eq!(follower.follow_if_due().unwrap(), None);
assert!(!follower.has("u2"), "which is the whole point of the interval");
}
#[test]
fn has_staged_writes_answers_whether_a_flush_would_do_anything() {
let (_dir, mut table) = open_table();
assert!(!table.has_staged_writes(), "a table nobody staged against");
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
assert!(!table.has_staged_writes(), "put stages and flushes inside the one call");
table.stage_put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
assert!(table.has_staged_writes());
assert_eq!(table.stats().queue_depth, 1);
table.flush().unwrap();
assert!(
!table.has_staged_writes(),
"and the window is empty again once it lands"
);
}
#[test]
fn keys_has_len_and_stats_report_the_live_records_not_the_bytes_behind_them() {
let (_dir, mut table) = open_table();
assert!(table.is_empty());
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
table.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
table.patch("u1", &json!({ "age": 31 }), false).unwrap();
table.delete("u2", false).unwrap();
let mut keys: Vec<&String> = table.keys().collect();
keys.sort();
let stats = table.stats();
assert_eq!(keys, vec!["u1"]);
assert_eq!(table.len(), 1, "a tombstoned key is not a record");
assert!(!table.is_empty());
assert!(table.has("u1"));
assert!(!table.has("u2"));
assert!(!table.has("never-written"));
assert_eq!(stats.records, 1);
assert!(
stats.dead_bytes > 0,
"the superseded version and the tombstone are dead bytes"
);
assert_eq!(stats.log_bytes, stats.live_bytes + stats.dead_bytes);
assert_eq!(stats.seq, 4, "one seq per appended line, tombstone included");
assert_eq!(stats.gen, 0);
}
#[test]
fn pending_is_empty_between_calls_because_every_write_commits_inside_its_own_call() {
let (_dir, mut table) = open_table();
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
assert!(table.pending("u1").is_empty());
assert!(table.pending("never-written").is_empty());
assert_eq!(table.stats().queue_depth, 0);
}
mod staging {
use super::*;
#[test]
fn a_staged_put_is_read_back_before_it_is_a_record() {
let (_dir, mut table) = open_table();
let staged = table.stage_put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
assert_eq!(staged.get("name"), Some(&json!("Ania")));
assert_eq!(table.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
assert!(table.has("u1"));
assert_eq!(table.len(), 0, "size counts the registry, never the window");
assert!(table.keys().next().is_none(), "and so does keys()");
assert_eq!(table.stats().queue_depth, 1);
assert_eq!(
table.pending("u1"),
HashSet::from(["id".to_string(), "name".to_string()])
);
table.flush().unwrap();
assert_eq!(table.len(), 1);
assert_eq!(table.stats().queue_depth, 0);
assert!(table.pending("u1").is_empty());
}
#[test]
fn a_staged_delete_answers_gone_before_its_tombstone_is_written() {
let (_dir, mut table) = open_table();
table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
assert!(
table.stage_delete("u1", false).unwrap(),
"it says what it removed right away"
);
assert_eq!(table.get("u1").unwrap(), None);
assert!(!table.has("u1"));
assert_eq!(table.len(), 1, "the record is a record until the tombstone lands");
table.flush().unwrap();
assert_eq!(table.len(), 0);
assert_eq!(table.get("u1").unwrap(), None);
}
#[test]
fn a_staged_write_outranks_a_peers_committed_one_on_the_same_key() {
let (_dir, mut writer, mut follower) = two_handles();
follower
.stage_put(&json!({ "id": "u1", "name": "Ania" }), false)
.unwrap();
writer.put(&json!({ "id": "u1", "name": "Zosia" }), false).unwrap();
assert!(follower.refresh().unwrap().changed());
assert_eq!(
follower.get("u1").unwrap().unwrap().get("name"),
Some(&json!("Ania")),
"the foreign write landed in the registry, but the window is read first"
);
follower.flush().unwrap();
assert_eq!(follower.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
assert_eq!(follower.len(), 1);
}
#[test]
fn a_staged_patch_claims_the_fields_it_changes_and_no_others() {
let (_dir, mut table) = open_table();
table
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
table.stage_patch("u1", &json!({ "age": 31 }), false).unwrap();
table.stage_patch("u1", &json!({ "name": "Anna" }), false).unwrap();
assert_eq!(
table.pending("u1"),
HashSet::from(["age".to_string(), "name".to_string()])
);
let refused = table.stage_patch("u1", &json!({ "age": 32 }), false).unwrap_err();
assert!(matches!(refused, TableError::Conflict(_)));
table.stage_patch("u1", &json!({ "age": 44 }), true).unwrap();
table.flush().unwrap();
let found = table.get("u1").unwrap().unwrap();
assert_eq!(
found.get("age"),
Some(&json!(44)),
"the forced patch is the one that stuck"
);
assert_eq!(
found.get("name"),
Some(&json!("Anna")),
"and it merged over the other one"
);
}
#[test]
fn a_read_of_committed_state_flushes_the_window_first() {
let (_dir, mut table) = open_table();
table
.stage_put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
let spec = QueryBuilder::new().r#where("age", ComparisonOp::Gt, json!(25)).spec();
assert_eq!(table.query_following(&spec).unwrap().keys, vec!["u1".to_string()]);
assert_eq!(table.stats().queue_depth, 0);
table
.stage_put(&json!({ "id": "u2", "name": "Piotr", "age": 24 }), false)
.unwrap();
let (counted, aggregate) = QueryBuilder::new().count().build_aggregate();
assert_eq!(table.aggregate_following(&counted, &aggregate).unwrap().count, 2);
assert_eq!(table.stats().queue_depth, 0);
table
.stage_put(&json!({ "id": "u3", "name": "Kasia", "age": 51 }), false)
.unwrap();
table.compact().unwrap();
assert_eq!(
table.stats().queue_depth,
0,
"compaction would have rewritten the log without it"
);
assert_eq!(table.len(), 3);
assert_eq!(table.get("u3").unwrap().unwrap().get("name"), Some(&json!("Kasia")));
}
#[test]
fn a_trigger_fires_on_the_flush_rather_than_on_the_staging() {
let (_dir, mut table) = open_table();
table.set_triggers(vec![self_audit_trigger()]);
table.stage_put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
assert_eq!(
table.get("audit-u1").unwrap(),
None,
"an uncommitted write has no effect yet"
);
table.flush().unwrap();
assert_eq!(
table.get("audit-u1").unwrap().unwrap().get("name"),
Some(&json!("Ania"))
);
}
}
#[test]
fn stats_counts_a_rebuild_as_a_generation() {
let (_dir, mut writer, mut follower) = two_handles();
writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
follower.refresh().unwrap();
assert_eq!(follower.stats().gen, 0);
writer.compact().unwrap();
follower.refresh().unwrap();
assert_eq!(follower.stats().gen, 1);
}
fn docs_schema() -> TableSchema {
parse_schema(&json!({
"schemaVersion": 1,
"table": "docs",
"primaryKey": "id",
"fields": {
"id": { "type": "string" },
"cover": { "type": "image" },
},
}))
.unwrap()
}
fn open_docs_table() -> (tempfile::TempDir, Table) {
let dir = tempdir().unwrap();
let path = dir.path().join("docs.jsonl");
let table = Table::open(docs_schema(), &path).unwrap();
(dir, table)
}
#[test]
fn a_put_with_an_image_field_writes_its_bytes_under_the_default_images_directory() {
let (dir, mut table) = open_docs_table();
let record = table
.put(
&json!({ "id": "d1", "cover": { "content": "bG9nby1ieXRlcw==", "virtualPath": "/images/logo.png" } }),
false,
)
.unwrap();
let physical_path = record
.get("cover")
.unwrap()
.get("physicalPath")
.unwrap()
.as_str()
.unwrap();
assert_eq!(table.image_dir(), dir.path().join("docs.images"));
let on_disk = fs::read(table.image_dir().join(physical_path)).unwrap();
assert_eq!(on_disk, b"logo-bytes");
}
#[test]
fn a_second_record_with_the_same_image_content_does_not_rewrite_the_existing_file() {
let (_dir, mut table) = open_docs_table();
table
.put(
&json!({ "id": "d1", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/a.png" } }),
false,
)
.unwrap();
let file_path = table.image_dir().join(physical_path_of(b"same-bytes"));
let written_at = fs::metadata(&file_path).unwrap().modified().unwrap();
table
.put(
&json!({ "id": "d2", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/b.png" } }),
false,
)
.unwrap();
assert_eq!(fs::metadata(&file_path).unwrap().modified().unwrap(), written_at);
assert_eq!(fs::read(&file_path).unwrap(), b"same-bytes");
}
#[test]
fn the_images_directory_is_created_automatically_on_first_write() {
let (_dir, mut table) = open_docs_table();
assert!(!table.image_dir().exists());
table
.put(
&json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
false,
)
.unwrap();
assert!(table.image_dir().is_dir());
}
#[test]
fn a_patch_that_does_not_touch_the_image_field_still_validates() {
let (_dir, mut table) = open_docs_table();
table
.put(
&json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
false,
)
.unwrap();
let patched = table.patch("d1", &json!({}), false).unwrap();
assert_eq!(
patched.get("cover").unwrap().get("virtualPath"),
Some(&json!("/images/a.png"))
);
}
fn pages_schema() -> TableSchema {
parse_schema(&json!({
"schemaVersion": 1,
"table": "pages",
"primaryKey": "id",
"fields": {
"id": { "type": "string" },
"page": { "type": "markdown" },
},
}))
.unwrap()
}
fn open_pages_table() -> (tempfile::TempDir, Table) {
let dir = tempdir().unwrap();
let path = dir.path().join("pages.jsonl");
let table = Table::open(pages_schema(), &path).unwrap();
(dir, table)
}
fn pages_with_cover_schema() -> TableSchema {
parse_schema(&json!({
"schemaVersion": 1,
"table": "pages",
"primaryKey": "id",
"fields": {
"id": { "type": "string" },
"page": { "type": "markdown" },
"cover": { "type": "image" },
},
}))
.unwrap()
}
fn open_pages_with_cover_table() -> (tempfile::TempDir, Table) {
let dir = tempdir().unwrap();
let path = dir.path().join("pages.jsonl");
let table = Table::open(pages_with_cover_schema(), &path).unwrap();
(dir, table)
}
fn open_pages_with_notes_table() -> (tempfile::TempDir, Table) {
let schema = parse_schema(&json!({
"schemaVersion": 1,
"table": "pages",
"primaryKey": "id",
"fields": {
"id": { "type": "string" },
"page": { "type": "markdown" },
"notes": { "type": "markdown", "addressable": false },
},
}))
.unwrap();
let dir = tempdir().unwrap();
let path = dir.path().join("pages.jsonl");
let table = Table::open(schema, &path).unwrap();
(dir, table)
}
#[test]
fn a_non_addressable_field_links_to_an_addressable_one_and_the_link_resolves() {
let (_dir, mut table) = open_pages_with_notes_table();
let outcome = table
.put_with_warnings(
&json!({
"id": "a",
"page": { "content": "the target", "virtualPath": "/docs/a.md" },
"notes": { "content": "see [a](/docs/a.md)" },
}),
false,
)
.unwrap();
assert!(
outcome.warnings.is_empty(),
"the content of a non-addressable field is still scanned, and this link resolves: {:?}",
outcome.warnings
);
assert_eq!(
table.broken_links_of("a").unwrap(),
Vec::new(),
"asking on demand has to agree"
);
}
#[test]
fn a_broken_link_in_a_non_addressable_field_is_still_reported_against_that_field() {
let (_dir, mut table) = open_pages_with_notes_table();
let outcome = table
.put_with_warnings(
&json!({
"id": "a",
"page": { "content": "", "virtualPath": "/docs/a.md" },
"notes": { "content": "see [gone](/docs/missing.md)" },
}),
false,
)
.unwrap();
assert_eq!(
outcome.warnings,
vec![BrokenLink {
field: "notes".to_string(),
path: "/docs/missing.md".to_string()
}]
);
}
#[test]
fn nothing_can_link_to_a_non_addressable_field_because_it_holds_no_virtual_path() {
let (_dir, mut table) = open_pages_with_notes_table();
table
.put(
&json!({
"id": "a",
"page": { "content": "", "virtualPath": "/docs/a.md" },
"notes": { "content": "unreachable", "virtualPath": "/notes/a.md" },
}),
false,
)
.unwrap();
let outcome = table
.put_with_warnings(
&json!({
"id": "b",
"page": { "content": "[notes](/notes/a.md)", "virtualPath": "/docs/b.md" },
}),
false,
)
.unwrap();
assert_eq!(
table.registry.virtual_path_fields().cloned().collect::<Vec<String>>(),
vec!["page".to_string()],
"only the addressable field belongs in the virtual-path index"
);
assert_eq!(
outcome.warnings,
vec![BrokenLink {
field: "page".to_string(),
path: "/notes/a.md".to_string()
}],
"a path only a non-addressable field carries is not a link target"
);
}
#[test]
fn two_records_may_share_the_path_of_a_non_addressable_field() {
let (_dir, mut table) = open_pages_with_notes_table();
let record = |id: &str| {
json!({
"id": id,
"page": { "content": "", "virtualPath": format!("/docs/{id}.md") },
"notes": { "content": "x", "virtualPath": "/notes/shared.md" },
})
};
table.put(&record("a"), false).unwrap();
let second = table.put(&record("b"), false);
assert!(
second.is_ok(),
"an unindexed path enforces no uniqueness: {:?}",
second.err()
);
}
#[test]
fn a_non_addressable_field_needs_no_path_of_its_own_at_all() {
let (_dir, mut table) = open_pages_with_notes_table();
table
.put(
&json!({ "id": "a", "page": { "content": "", "virtualPath": "/docs/a.md" }, "notes": { "content": "x" } }),
false,
)
.unwrap();
let stored = table.get("a").unwrap().unwrap();
assert_eq!(stored["notes"], json!({ "content": "x" }));
}
#[test]
fn a_table_opened_with_no_link_parsing_option_defaults_to_eager() {
let (_dir, table) = open_pages_table();
assert_eq!(table.link_parsing(), LinkParsing::Eager);
}
#[test]
fn eager_put_of_a_broken_link_still_succeeds_but_carries_a_warning() {
let (_dir, mut table) = open_pages_table();
let outcome = table
.put_with_warnings(
&json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
false,
)
.unwrap();
assert!(table.get("a").unwrap().is_some());
assert_eq!(
outcome.warnings,
vec![BrokenLink {
field: "page".to_string(),
path: "/docs/missing.md".to_string()
}]
);
}
#[test]
fn eager_put_of_a_link_to_an_existing_page_carries_no_warnings() {
let (_dir, mut table) = open_pages_table();
table
.put_with_warnings(
&json!({ "id": "a", "page": { "content": "", "virtualPath": "/docs/a.md" } }),
false,
)
.unwrap();
let outcome = table
.put_with_warnings(
&json!({ "id": "b", "page": { "content": "[a](/docs/a.md)", "virtualPath": "/docs/b.md" } }),
false,
)
.unwrap();
assert!(outcome.warnings.is_empty());
}
#[test]
fn eager_put_of_a_link_to_a_cover_images_virtual_path_in_the_same_record_carries_no_warnings() {
let (_dir, mut table) = open_pages_with_cover_table();
let outcome = table
.put_with_warnings(
&json!({
"id": "a",
"page": { "content": "[cover](/images/a.png)", "virtualPath": "/docs/a.md" },
"cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" },
}),
false,
)
.unwrap();
assert!(outcome.warnings.is_empty());
}
#[test]
fn eager_put_of_a_link_to_a_cover_images_virtual_path_in_another_record_carries_no_warnings() {
let (_dir, mut table) = open_pages_with_cover_table();
table
.put_with_warnings(
&json!({ "id": "a", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
false,
)
.unwrap();
let outcome = table
.put_with_warnings(
&json!({ "id": "b", "page": { "content": "[cover](/images/a.png)", "virtualPath": "/docs/b.md" } }),
false,
)
.unwrap();
assert!(outcome.warnings.is_empty());
}
#[test]
fn eager_put_of_a_link_to_a_path_no_field_holds_still_carries_a_warning() {
let (_dir, mut table) = open_pages_with_cover_table();
table
.put_with_warnings(
&json!({ "id": "a", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
false,
)
.unwrap();
let outcome = table
.put_with_warnings(
&json!({ "id": "b", "page": { "content": "[nope](/images/missing.png)", "virtualPath": "/docs/b.md" } }),
false,
)
.unwrap();
assert_eq!(
outcome.warnings,
vec![BrokenLink {
field: "page".to_string(),
path: "/images/missing.png".to_string()
}]
);
}
#[test]
fn lazy_link_parsing_reports_no_automatic_warning_but_broken_links_of_finds_it_on_request() {
let (_dir, mut table) = open_pages_table();
table.set_link_parsing(LinkParsing::Lazy);
let outcome = table
.put_with_warnings(
&json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
false,
)
.unwrap();
assert!(outcome.warnings.is_empty());
let broken = table.broken_links_of("a").unwrap();
assert_eq!(
broken,
vec![BrokenLink {
field: "page".to_string(),
path: "/docs/missing.md".to_string()
}]
);
}
#[test]
fn asking_after_the_write_gives_an_eager_table_the_same_warnings_put_with_warnings_carried() {
let (_dir, mut table) = open_pages_table();
let outcome = table
.put_with_warnings(
&json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
false,
)
.unwrap();
assert_eq!(table.eager_link_warnings_of("a").unwrap(), outcome.warnings);
assert_eq!(
table.eager_link_warnings_of("a").unwrap(),
vec![BrokenLink {
field: "page".to_string(),
path: "/docs/missing.md".to_string()
}]
);
}
#[test]
fn asking_after_the_write_stays_silent_on_a_lazy_table_that_broken_links_of_would_answer_for() {
let (_dir, mut table) = open_pages_table();
table.set_link_parsing(LinkParsing::Lazy);
table
.put_with_warnings(
&json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
false,
)
.unwrap();
assert!(table.eager_link_warnings_of("a").unwrap().is_empty());
assert_eq!(table.broken_links_of("a").unwrap().len(), 1);
}
#[test]
fn plain_put_and_patch_still_return_a_bare_record_alongside_the_warning_variants() {
let (_dir, mut table) = open_pages_table();
let record = table
.put(
&json!({ "id": "a", "page": { "content": "no links here", "virtualPath": "/docs/a.md" } }),
false,
)
.unwrap();
assert_eq!(record.get("id"), Some(&json!("a")));
let patched = table
.patch(
"a",
&json!({ "page": { "content": "[missing](/docs/x.md)", "virtualPath": "/docs/a.md" } }),
false,
)
.unwrap();
assert_eq!(patched.get("id"), Some(&json!("a")));
}
#[test]
fn compact_drops_dead_versions_but_leaves_the_table_fully_usable() {
let (_dir, mut table) = open_table();
table
.put(
&json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
false,
)
.unwrap();
table
.put(
&json!({ "id": "u2", "name": "Piotr", "age": 24, "email": "u2@x.com" }),
false,
)
.unwrap();
table
.put(
&json!({ "id": "u3", "name": "Kasia", "age": 40, "email": "u3@x.com" }),
false,
)
.unwrap();
table.patch("u2", &json!({ "age": 25 }), false).unwrap();
table.delete("u3", false).unwrap();
let result = table.compact().unwrap();
assert_eq!(result.records, 2);
assert!(result.reclaimed_bytes > 0);
let u1 = table.get("u1").unwrap().unwrap();
assert_eq!(u1.get("name"), Some(&json!("Ania")));
let u2 = table.get("u2").unwrap().unwrap();
assert_eq!(u2.get("age"), Some(&json!(25)));
assert_eq!(table.get("u3").unwrap(), None);
table
.put(
&json!({ "id": "u4", "name": "Zosia", "age": 22, "email": "u4@x.com" }),
false,
)
.unwrap();
assert_eq!(table.get("u4").unwrap().unwrap().get("name"), Some(&json!("Zosia")));
table.snapshot().unwrap();
assert!(!table.needs_snapshot());
}
mod half_written_tail {
use std::io::Write as _;
use super::*;
const TORN: &[u8] = br#"{"_k":"u3","_op":"put","_seq":9,"_ts":1,"name":"Half"#;
fn seeded(path: &Path) -> u64 {
let mut table = Table::open(users_schema(), path).unwrap();
table
.put(
&json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
false,
)
.unwrap();
table
.put(
&json!({ "id": "u2", "name": "Piotr", "age": 40, "email": "u2@x.com" }),
false,
)
.unwrap();
table.flush().unwrap();
fs::metadata(path).unwrap().len()
}
fn tear(path: &Path) {
let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap();
file.write_all(TORN).unwrap();
}
fn tracked_size(table: &Table) -> u64 {
table.logs.log_of(UNPARTITIONED).unwrap().size()
}
#[test]
fn a_writable_open_cuts_the_torn_tail_off_the_log_so_the_next_append_stays_readable() {
let dir = tempdir().unwrap();
let path = dir.path().join("users.jsonl");
let healthy = seeded(&path);
tear(&path);
assert!(fs::metadata(&path).unwrap().len() > healthy);
let mut table = Table::open(users_schema(), &path).unwrap();
assert_eq!(tracked_size(&table), healthy);
assert_eq!(fs::metadata(&path).unwrap().len(), healthy);
table
.put(
&json!({ "id": "u3", "name": "Zosia", "age": 22, "email": "u3@x.com" }),
false,
)
.unwrap();
table.flush().unwrap();
let reopened = Table::open(users_schema(), &path).unwrap();
let mut keys: Vec<&str> = reopened.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["u1", "u2", "u3"]);
assert_eq!(reopened.get("u3").unwrap().unwrap().get("name"), Some(&json!("Zosia")));
let raw = fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = raw.trim().lines().collect();
assert_eq!(lines.len(), 3);
assert!(lines.iter().all(|line| serde_json::from_str::<Value>(line).is_ok()));
}
#[test]
fn a_readonly_open_moves_its_own_end_marker_and_leaves_the_torn_file_alone() {
let dir = tempdir().unwrap();
let path = dir.path().join("users.jsonl");
let healthy = seeded(&path);
tear(&path);
let torn_size = fs::metadata(&path).unwrap().len();
let (table, outcome) = Table::open_with_snapshot_and_declared_indexes(
users_schema(),
&path,
dir.path().join("users.idx"),
"",
0,
true,
&[],
)
.unwrap();
assert_eq!(outcome, RestoreOutcome::Rebuilt);
assert_eq!(tracked_size(&table), healthy);
assert_eq!(fs::metadata(&path).unwrap().len(), torn_size);
let mut keys: Vec<&str> = table.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["u1", "u2"]);
}
}
mod cypher {
use super::*;
use crate::crypto::{CipherOptions, MissingKeyPolicy};
use std::fs;
fn key(byte: u8) -> Vec<u8> {
vec![byte; 32]
}
fn secrets_schema() -> TableSchema {
parse_schema(&json!({
"schemaVersion": 1,
"table": "secrets",
"primaryKey": "id",
"fields": {
"id": { "type": "string" },
"label": { "type": "string", "index": "exact" },
"token": { "type": "string", "cypher": true },
"pin": { "type": "int", "cypher": true },
},
}))
.unwrap()
}
fn cipher_with(keys: &[(&str, u8)], on_missing_key: Option<MissingKeyPolicy>) -> FieldCipher {
FieldCipher::new(CipherOptions {
keys: keys.iter().map(|(id, byte)| (id.to_string(), key(*byte))).collect(),
key_id: None,
on_missing_key,
})
.unwrap()
}
fn open_secrets(dir: &std::path::Path, cipher: Option<FieldCipher>) -> Table {
let mut table = Table::open(secrets_schema(), dir.join("secrets.jsonl")).unwrap();
table.set_cipher(cipher);
table
}
#[test]
fn never_writes_the_plaintext_into_the_log() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table
.put(
&json!({ "id": "s1", "label": "deploy", "token": "hunter2-super-secret", "pin": 4321 }),
false,
)
.unwrap();
let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
assert!(!log.contains("hunter2-super-secret"));
assert!(!log.contains("4321"));
assert!(log.contains("$cypher$v1$k1$"));
assert!(log.contains("deploy"));
}
#[test]
fn hands_the_plaintext_back_on_read_with_its_type_intact() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table
.put(&json!({ "id": "s1", "token": "hunter2", "pin": 4321 }), false)
.unwrap();
let found = table.get("s1").unwrap().unwrap();
assert_eq!(found.get("token"), Some(&json!("hunter2")));
assert_eq!(found.get("pin"), Some(&json!(4321)));
}
#[test]
fn a_staged_value_reads_back_plain_and_still_lands_sealed() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
let path = dir.path().join("secrets.jsonl");
table
.stage_put(&json!({ "id": "s1", "label": "deploy", "token": "top-secret" }), false)
.unwrap();
assert_eq!(
table.get("s1").unwrap().unwrap().get("token"),
Some(&json!("top-secret"))
);
assert!(
fs::read_to_string(&path).unwrap_or_default().is_empty(),
"nothing is on disk yet"
);
table.flush().unwrap();
let written = fs::read_to_string(&path).unwrap();
assert!(!written.contains("top-secret"), "the plaintext never reached the log");
assert!(written.contains("$cypher$v1$k1$"));
assert_eq!(
table.get("s1").unwrap().unwrap().get("token"),
Some(&json!("top-secret"))
);
}
#[test]
fn two_equal_values_get_two_different_envelopes() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s1", "token": "same" }), false).unwrap();
table.put(&json!({ "id": "s2", "token": "same" }), false).unwrap();
let lines: Vec<String> = fs::read_to_string(dir.path().join("secrets.jsonl"))
.unwrap()
.trim()
.lines()
.map(str::to_string)
.collect();
let envelopes: Vec<String> = lines
.iter()
.map(|line| {
serde_json::from_str::<Value>(line)
.unwrap()
.get("token")
.unwrap()
.as_str()
.unwrap()
.to_string()
})
.collect();
assert_ne!(envelopes[0], envelopes[1]);
}
#[test]
fn leaves_null_alone() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s1", "token": Value::Null }), false).unwrap();
let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
assert!(log.contains("\"token\":null"));
}
#[test]
fn survives_a_patch_of_an_encrypted_field() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table
.put(
&json!({ "id": "s1", "label": "deploy", "token": "first", "pin": 1 }),
false,
)
.unwrap();
table.patch("s1", &json!({ "token": "second" }), false).unwrap();
assert_eq!(table.get("s1").unwrap().unwrap().get("token"), Some(&json!("second")));
assert_eq!(table.get("s1").unwrap().unwrap().get("pin"), Some(&json!(1)));
let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
assert!(!log.contains("\"second\""));
}
#[test]
fn survives_compaction_without_a_key() {
let dir = tempdir().unwrap();
{
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s1", "token": "keep-me" }), false).unwrap();
table
.put(&json!({ "id": "s1", "token": "keep-me-instead" }), false)
.unwrap();
}
{
let mut blind = open_secrets(dir.path(), None);
blind.compact().unwrap();
}
let reopened = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
assert_eq!(
reopened.get("s1").unwrap().unwrap().get("token"),
Some(&json!("keep-me-instead"))
);
}
#[test]
fn matches_a_predicate_on_an_encrypted_field_by_scanning() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s1", "token": "alpha" }), false).unwrap();
table.put(&json!({ "id": "s2", "token": "beta" }), false).unwrap();
let spec = QueryBuilder::new()
.r#where("token", ComparisonOp::Eq, json!("beta"))
.spec();
let found = table.query(&spec).unwrap();
assert_eq!(found.keys, vec!["s2".to_string()]);
}
#[test]
fn refuses_to_write_an_encrypted_field_with_no_key() {
let dir = tempdir().unwrap();
let mut blind = open_secrets(dir.path(), None);
let error = blind.put(&json!({ "id": "s9", "token": "nope" }), false).unwrap_err();
assert!(matches!(error, TableError::Cipher(_)));
}
#[test]
fn refuses_to_read_without_the_key_that_wrote_the_value() {
let dir = tempdir().unwrap();
{
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s1", "token": "secret" }), false).unwrap();
}
let wrong = open_secrets(dir.path(), Some(cipher_with(&[("other", 2)], None)));
let error = wrong.get("s1").unwrap_err();
let TableError::Cipher(cipher) = &error else {
panic!("a failed decrypt is a cipher refusal, not an io failure: {error:?}");
};
assert_eq!(cipher.table, "secrets");
assert_eq!(cipher.field, "token");
assert_eq!(cipher.key_id.as_deref(), Some("k1"));
assert!(error.to_string().contains("was not given"));
}
#[test]
fn refuses_to_read_with_no_key_at_all() {
let dir = tempdir().unwrap();
{
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s1", "token": "secret" }), false).unwrap();
}
let blind = open_secrets(dir.path(), None);
let error = blind.get("s1").unwrap_err();
assert!(error.to_string().contains("cannot be read without a key"));
}
#[test]
fn omits_the_field_when_the_policy_says_so() {
let dir = tempdir().unwrap();
{
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table
.put(&json!({ "id": "s1", "label": "deploy", "token": "secret" }), false)
.unwrap();
}
let partial = open_secrets(
dir.path(),
Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Omit))),
);
let found = partial.get("s1").unwrap().unwrap();
assert_eq!(found.get("label"), Some(&json!("deploy")));
assert_eq!(found.get("token"), None);
}
#[test]
fn hands_back_the_envelope_when_the_policy_says_raw() {
let dir = tempdir().unwrap();
{
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s1", "token": "secret" }), false).unwrap();
}
let raw = open_secrets(
dir.path(),
Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Raw))),
);
let found = raw.get("s1").unwrap().unwrap();
assert!(found.get("token").unwrap().as_str().unwrap().contains("$cypher$v1$k1$"));
}
const MASQUERADE: &str = "$cypher$v1$k1$not$real";
fn token_in(path: &std::path::Path, key: &str) -> String {
fs::read_to_string(path)
.unwrap()
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.rfind(|entry| entry["_k"] == json!(key))
.unwrap()["token"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn a_value_that_only_looks_like_an_envelope_is_sealed_like_any_other() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table
.put(&json!({ "id": "s1", "label": "deploy", "token": MASQUERADE }), false)
.unwrap();
let path = dir.path().join("secrets.jsonl");
let stored = token_in(&path, "s1");
assert_ne!(stored, MASQUERADE, "the value reached the log in the clear");
assert!(!fs::read_to_string(&path).unwrap().contains("$not$real"));
assert_eq!(
table.get("s1").unwrap().unwrap().get("token"),
Some(&json!(MASQUERADE)),
"and it still reads back as what was written"
);
}
#[test]
fn a_copy_between_databases_carries_the_envelope_byte_for_byte() {
let source = tempdir().unwrap();
{
let mut table = open_secrets(source.path(), Some(cipher_with(&[("k1", 1)], None)));
table
.put(&json!({ "id": "s1", "label": "deploy", "token": "hunter2" }), false)
.unwrap();
}
let envelope = {
let raw = open_secrets(
source.path(),
Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Raw))),
);
raw.get("s1").unwrap().unwrap()["token"].as_str().unwrap().to_string()
};
let target = tempdir().unwrap();
{
let mut table = open_secrets(target.path(), None);
table
.put_sealed(
&json!({ "id": "s1", "label": "deploy", "token": envelope }),
&["token".to_string()],
false,
)
.unwrap();
}
assert_eq!(
token_in(&target.path().join("secrets.jsonl"), "s1"),
envelope,
"the copy re-sealed a value it was told was already sealed"
);
let restored = open_secrets(target.path(), Some(cipher_with(&[("k1", 1)], None)));
assert_eq!(
restored.get("s1").unwrap().unwrap().get("token"),
Some(&json!("hunter2"))
);
}
#[test]
fn a_carried_field_is_refused_when_it_is_not_an_envelope() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
let error = table
.put_sealed(
&json!({ "id": "s1", "token": MASQUERADE }),
&["token".to_string()],
false,
)
.unwrap_err();
assert!(error.to_string().contains("is not a cypher envelope"), "{error}");
assert!(table.is_empty(), "the refused write still landed");
}
#[test]
fn a_carried_field_is_refused_when_the_envelope_belongs_to_another_record() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s2", "token": "yours" }), false).unwrap();
let stolen = {
let raw = open_secrets(
dir.path(),
Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Raw))),
);
raw.get("s2").unwrap().unwrap()["token"].as_str().unwrap().to_string()
};
let error = table
.put_sealed(&json!({ "id": "s1", "token": stolen }), &["token".to_string()], false)
.unwrap_err();
assert!(
error.to_string().contains("does not open for this record and field"),
"{error}"
);
}
#[test]
fn a_field_that_is_not_declared_cypher_cannot_be_carried() {
let dir = tempdir().unwrap();
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
let error = table
.put_sealed(&json!({ "id": "s1", "label": "deploy" }), &["label".to_string()], false)
.unwrap_err();
assert!(error.to_string().contains("not declared \"cypher\""), "{error}");
}
#[test]
fn a_carried_field_does_not_spare_another_write_in_the_same_window() {
let source = tempdir().unwrap();
{
let mut table = open_secrets(source.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s1", "token": "hunter2" }), false).unwrap();
}
let envelope = token_in(&source.path().join("secrets.jsonl"), "s1");
let target = tempdir().unwrap();
let mut table = open_secrets(target.path(), Some(cipher_with(&[("k1", 1)], None)));
table
.stage_put(&json!({ "id": "s2", "token": MASQUERADE }), false)
.unwrap();
table
.put_sealed(
&json!({ "id": "s1", "token": envelope.clone() }),
&["token".to_string()],
false,
)
.unwrap();
let path = target.path().join("secrets.jsonl");
assert_eq!(token_in(&path, "s1"), envelope);
assert_ne!(
token_in(&path, "s2"),
MASQUERADE,
"the import's claim spared a record it was never about"
);
}
#[test]
fn refuses_a_value_moved_to_another_record() {
let dir = tempdir().unwrap();
{
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
table.put(&json!({ "id": "s1", "token": "mine" }), false).unwrap();
table.put(&json!({ "id": "s2", "token": "yours" }), false).unwrap();
}
let path = dir.path().join("secrets.jsonl");
let lines: Vec<String> = fs::read_to_string(&path)
.unwrap()
.trim()
.lines()
.map(str::to_string)
.collect();
let mut first: serde_json::Map<String, Value> = serde_json::from_str(&lines[0]).unwrap();
let mut second: serde_json::Map<String, Value> = serde_json::from_str(&lines[1]).unwrap();
let stolen = first.remove("token").unwrap();
second.insert("token".to_string(), stolen);
fs::write(
&path,
format!(
"{}\n{}\n",
serde_json::to_string(&first).unwrap(),
serde_json::to_string(&second).unwrap()
),
)
.unwrap();
let reopened = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
let error = reopened.get("s2").unwrap_err();
assert!(error.to_string().contains("authentication tag"));
}
fn cipher_writing(
keys: &[(&str, u8)],
write_id: &str,
on_missing_key: Option<MissingKeyPolicy>,
) -> FieldCipher {
FieldCipher::new(CipherOptions {
keys: keys.iter().map(|(id, byte)| ((*id).to_string(), key(*byte))).collect(),
key_id: Some(write_id.to_string()),
on_missing_key,
})
.unwrap()
}
fn seeded_across_two_keys(dir: &std::path::Path) -> Table {
let mut old = open_secrets(dir, Some(cipher_with(&[("k1", 1)], None)));
old.put(
&json!({ "id": "s1", "label": "old", "token": "first", "pin": 1111 }),
false,
)
.unwrap();
drop(old);
let mut table = open_secrets(dir, Some(cipher_writing(&[("k1", 1), ("k2", 2)], "k2", None)));
table
.put(
&json!({ "id": "s2", "label": "new", "token": "second", "pin": 2222 }),
false,
)
.unwrap();
table
}
fn envelope_key_ids_in(path: &std::path::Path) -> Vec<String> {
fs::read_to_string(path)
.unwrap()
.lines()
.filter(|line| !line.is_empty())
.map(|line| {
let record: serde_json::Map<String, Value> = serde_json::from_str(line).unwrap();
crate::crypto::envelope_key_id(&record["token"]).unwrap().to_string()
})
.collect()
}
#[test]
fn rotation_re_encrypts_only_the_record_still_on_the_retired_key() {
let dir = tempdir().unwrap();
let mut table = seeded_across_two_keys(dir.path());
let result = table.rotate_cypher_keys().unwrap();
assert_eq!(
(result.rotated, result.already_current, result.skipped),
(1, 1, 0),
"only \"s1\" was on k1"
);
let written = envelope_key_ids_in(&dir.path().join("secrets.jsonl"));
assert_eq!(
written,
vec!["k1".to_string(), "k2".to_string(), "k2".to_string()],
"the k1 line stays in the append-only log; the rotation appends the k2 version after it"
);
}
#[test]
fn a_second_rotation_reports_nothing_left_to_do() {
let dir = tempdir().unwrap();
let mut table = seeded_across_two_keys(dir.path());
table.rotate_cypher_keys().unwrap();
let again = table.rotate_cypher_keys().unwrap();
assert_eq!(
(again.rotated, again.skipped),
(0, 0),
"a rotation nothing needs must rewrite nothing"
);
assert_eq!(again.already_current, 2);
}
#[test]
fn every_value_still_reads_back_as_itself_after_a_rotation() {
let dir = tempdir().unwrap();
let mut table = seeded_across_two_keys(dir.path());
table.rotate_cypher_keys().unwrap();
for (key, token, pin, label) in [("s1", "first", 1111, "old"), ("s2", "second", 2222, "new")] {
let found = table.get(key).unwrap().unwrap();
assert_eq!(found.get("token"), Some(&json!(token)), "{key}");
assert_eq!(found.get("pin"), Some(&json!(pin)), "{key}");
assert_eq!(found.get("label"), Some(&json!(label)), "{key}");
}
}
#[test]
fn a_rotated_record_reads_back_with_the_new_key_alone() {
let dir = tempdir().unwrap();
let mut table = seeded_across_two_keys(dir.path());
table.rotate_cypher_keys().unwrap();
drop(table);
let without_the_old_key = open_secrets(dir.path(), Some(cipher_with(&[("k2", 2)], None)));
assert_eq!(
without_the_old_key.get("s1").unwrap().unwrap().get("token"),
Some(&json!("first")),
"the whole point of rotating: k1 can now be retired"
);
}
#[test]
fn a_table_with_no_cypher_field_answers_zero_without_reading_anything() {
let dir = tempdir().unwrap();
let mut table = Table::open(users_schema(), dir.path().join("users.jsonl")).unwrap();
table
.put(
&json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
false,
)
.unwrap();
let result = table.rotate_cypher_keys().unwrap();
assert_eq!(result, RotationResult::default());
}
#[test]
fn a_missing_key_refuses_the_run_under_the_default_policy() {
let dir = tempdir().unwrap();
drop(seeded_across_two_keys(dir.path()));
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k2", 2)], None)));
let error = table.rotate_cypher_keys().unwrap_err();
assert!(
error.to_string().contains("s1"),
"the refusal has to name the record: {error}"
);
assert!(error.to_string().contains("k1"), "and the key it wanted: {error}");
}
#[test]
fn a_missing_key_is_skipped_and_counted_when_the_policy_says_so() {
let dir = tempdir().unwrap();
drop(seeded_across_two_keys(dir.path()));
let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k2", 2)], Some(MissingKeyPolicy::Raw))));
let result = table.rotate_cypher_keys().unwrap();
assert_eq!(
(result.rotated, result.already_current, result.skipped),
(0, 1, 1),
"the unreadable record is left alone rather than re-sealed from its own envelope"
);
let written = envelope_key_ids_in(&dir.path().join("secrets.jsonl"));
assert_eq!(
written,
vec!["k1".to_string(), "k2".to_string()],
"nothing was appended"
);
}
#[test]
fn rotation_reaches_every_partition_of_a_partitioned_table() {
let schema = parse_schema(&json!({
"schemaVersion": 1,
"table": "secrets",
"primaryKey": "id",
"partitionBy": "tenant",
"fields": {
"id": { "type": "string" },
"tenant": { "type": "string", "required": true },
"token": { "type": "string", "cypher": true },
},
}))
.unwrap();
let dir = tempdir().unwrap();
let path = dir.path().join("secrets.jsonl");
let open = |cipher| {
let mut table = Table::open(schema.clone(), &path).unwrap();
table.set_cipher(Some(cipher));
table
};
let mut old = open(cipher_with(&[("k1", 1)], None));
old.put(&json!({ "id": "s1", "tenant": "acme", "token": "a" }), false)
.unwrap();
old.put(&json!({ "id": "s2", "tenant": "globex", "token": "b" }), false)
.unwrap();
drop(old);
let mut table = open(cipher_writing(&[("k1", 1), ("k2", 2)], "k2", None));
let result = table.rotate_cypher_keys().unwrap();
assert_eq!((result.rotated, result.already_current), (2, 0));
for (key, tenant, token) in [("s1", "acme", "a"), ("s2", "globex", "b")] {
let found = table.get(key).unwrap().unwrap();
assert_eq!(found.get("token"), Some(&json!(token)));
assert_eq!(
found.get("tenant"),
Some(&json!(tenant)),
"the record must not change partition"
);
}
for tenant in ["acme", "globex"] {
let log = fs::read_to_string(dir.path().join("secrets").join(format!("{tenant}.jsonl"))).unwrap();
assert!(log.contains("$cypher$v1$k2$"), "{tenant} was rotated in its own file");
}
}
#[test]
fn rotation_is_refused_on_a_readonly_handle() {
let dir = tempdir().unwrap();
drop(seeded_across_two_keys(dir.path()));
let mut table = Table::open_with_snapshot_and_declared_indexes(
secrets_schema(),
dir.path().join("secrets.jsonl"),
dir.path().join("secrets.idx"),
"",
0,
true,
&[],
)
.unwrap()
.0;
table.set_cipher(Some(cipher_writing(&[("k1", 1), ("k2", 2)], "k2", None)));
let error = table.rotate_cypher_keys().unwrap_err();
assert!(error.to_string().contains("readonly"), "{error}");
}
}
mod cypher_txn {
use super::*;
use crate::crypto::CipherOptions;
use std::collections::HashMap;
use std::fs;
fn key(byte: u8) -> Vec<u8> {
vec![byte; 32]
}
fn secrets_schema() -> TableSchema {
parse_schema(&json!({
"schemaVersion": 1,
"table": "secrets",
"primaryKey": "id",
"fields": {
"id": { "type": "string" },
"token": { "type": "string", "cypher": true },
},
}))
.unwrap()
}
#[test]
fn prepare_txn_ops_seals_the_line_but_keeps_the_prepared_record_plain() {
let dir = tempdir().unwrap();
let cipher = FieldCipher::new(CipherOptions {
keys: HashMap::from([("k1".to_string(), key(1))]),
key_id: None,
on_missing_key: None,
})
.unwrap();
let mut table = Table::open(secrets_schema(), dir.path().join("secrets.jsonl")).unwrap();
table.set_cipher(Some(cipher));
let prepared = table
.prepare_txn_ops(&[TxnOp::Put(json!({ "id": "s1", "token": "top-secret" }))])
.unwrap();
assert_eq!(prepared.len(), 1);
assert!(prepared[0].line.contains("$cypher$v1$k1$"));
assert!(!prepared[0].line.contains("top-secret"));
assert_eq!(
prepared[0].record.as_ref().unwrap().get("token"),
Some(&json!("top-secret"))
);
table.apply_txn_entries(prepared).unwrap();
let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
assert!(!log.contains("top-secret"));
assert_eq!(
table.get("s1").unwrap().unwrap().get("token"),
Some(&json!("top-secret"))
);
}
}
mod partitions {
use std::fs;
use super::*;
use crate::query::predicates::ComparisonOp;
use crate::registry::fulltext_index::SearchMode;
fn notes_schema(partitioned: bool) -> TableSchema {
let mut spec = json!({
"schemaVersion": 1,
"table": "notes",
"primaryKey": "id",
"fields": {
"id": { "type": "string" },
"kind": { "type": "string", "required": true, "index": "exact" },
"title": { "type": "string", "required": true, "fulltext": true },
"body": { "type": "string", "fulltext": true },
"slug": { "type": "string", "unique": true },
},
});
if partitioned {
spec.as_object_mut()
.unwrap()
.insert("partitionBy".into(), json!("kind"));
}
parse_schema(&spec).unwrap()
}
fn seed() -> Vec<Value> {
vec![
json!({ "id": "n1", "kind": "note", "title": "Backend developer", "body": "silnik zapytań", "slug": "n1" }),
json!({ "id": "n2", "kind": "note", "title": "Frontend notes", "body": "react", "slug": "n2" }),
json!({ "id": "a1", "kind": "adr", "title": "Backend decision", "body": "silnik składowania", "slug": "a1" }),
json!({ "id": "r1", "kind": "rule", "title": "Naming rule", "body": "kebab case", "slug": "r1" }),
]
}
fn log_path(dir: &std::path::Path) -> std::path::PathBuf {
dir.join("notes.jsonl")
}
fn open_notes(dir: &std::path::Path) -> Table {
Table::open(notes_schema(true), log_path(dir)).unwrap()
}
fn partition_lines(dir: &std::path::Path, part: &str) -> Vec<Value> {
fs::read_to_string(dir.join("notes").join(format!("{part}.jsonl")))
.unwrap()
.trim()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
#[test]
fn keeps_one_log_file_per_partition_value() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
for note in seed() {
notes.put(¬e, false).unwrap();
}
assert!(!log_path(dir.path()).exists());
assert_eq!(partition_lines(dir.path(), "note").len(), 2);
assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
assert_eq!(partition_lines(dir.path(), "rule").len(), 1);
for line in partition_lines(dir.path(), "adr") {
assert_eq!(line.get("kind"), Some(&json!("adr")));
}
}
#[test]
fn history_of_a_key_that_moved_partitions_reads_as_one_list_across_both_files() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
notes
.put(
&json!({ "id": "n1", "kind": "note", "title": "First", "slug": "n1" }),
false,
)
.unwrap();
notes.patch("n1", &json!({ "title": "Second" }), false).unwrap();
let moved = notes.put(
&json!({ "id": "n1", "kind": "adr", "title": "Third", "slug": "n1" }),
false,
);
assert!(moved.is_err(), "a live key may not change partition");
notes.delete("n1", false).unwrap();
notes
.put(
&json!({ "id": "n1", "kind": "adr", "title": "Third", "slug": "n1" }),
false,
)
.unwrap();
let history = notes.history("n1").unwrap();
assert_eq!(
history.scanned,
vec![
dir.path().join("notes").join("adr.jsonl"),
dir.path().join("notes").join("note.jsonl"),
]
);
let shape: Vec<(u64, LogOp, &str, Option<&Value>)> = history
.entries
.iter()
.map(|entry| {
(
entry.seq,
entry.op,
entry.partition.as_str(),
entry.document.as_ref().and_then(|d| d.get("title")),
)
})
.collect();
assert_eq!(
shape,
vec![
(1, LogOp::Put, "note", Some(&json!("First"))),
(2, LogOp::Put, "note", Some(&json!("Second"))),
(3, LogOp::Del, "note", None),
(4, LogOp::Put, "adr", Some(&json!("Third"))),
]
);
assert!(notes.history("n2").unwrap().entries.is_empty());
}
#[test]
fn history_of_a_declared_but_unsplit_table_reads_the_flat_log() {
let dir = tempdir().unwrap();
{
let mut flat = Table::open(notes_schema(false), log_path(dir.path())).unwrap();
flat.put(
&json!({ "id": "n1", "kind": "note", "title": "First", "slug": "n1" }),
false,
)
.unwrap();
flat.patch("n1", &json!({ "title": "Second" }), false).unwrap();
}
let (notes, _) = Table::open_with_snapshot_and_declared_indexes(
notes_schema(true),
log_path(dir.path()),
dir.path().join("notes.idx"),
"hash",
0,
true,
&[],
)
.unwrap();
let history = notes.history("n1").unwrap();
assert_eq!(history.scanned, vec![log_path(dir.path())]);
assert_eq!(
history
.entries
.iter()
.map(|entry| (entry.seq, entry.partition.clone()))
.collect::<Vec<_>>(),
vec![(1, UNPARTITIONED.to_string()), (2, UNPARTITIONED.to_string())]
);
}
#[test]
fn reads_every_partition_back_after_a_restart() {
let dir = tempdir().unwrap();
{
let mut notes = open_notes(dir.path());
for note in seed() {
notes.put(¬e, false).unwrap();
}
}
let notes = open_notes(dir.path());
assert_eq!(notes.registry.records(), 4);
assert_eq!(
notes.get("a1").unwrap().unwrap().get("title"),
Some(&json!("Backend decision"))
);
assert_eq!(notes.get("r1").unwrap().unwrap().get("kind"), Some(&json!("rule")));
let spec = QueryBuilder::new()
.r#where("kind", ComparisonOp::Eq, json!("note"))
.spec();
let mut found = notes.query(&spec).unwrap().keys;
found.sort();
assert_eq!(found, vec!["n1".to_string(), "n2".to_string()]);
}
#[test]
fn pairs_the_index_snapshot_with_every_partition_it_covers() {
let dir = tempdir().unwrap();
let snapshot_path = dir.path().join("notes.idx");
let (mut notes, _) =
Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
for note in seed() {
notes.put(¬e, false).unwrap();
}
notes.snapshot().unwrap();
let snapshot = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
let parts = snapshot.header.parts.unwrap();
assert_eq!(
parts.iter().map(|part| part.part.clone()).collect::<Vec<_>>(),
vec!["adr".to_string(), "note".to_string(), "rule".to_string()]
);
for part in &parts {
let on_disk = fs::metadata(dir.path().join("notes").join(format!("{}.jsonl", part.part))).unwrap();
assert_eq!(part.watermark, on_disk.len());
assert!(!part.log_hash.is_empty());
}
assert_eq!(snapshot.header.log_hash, None);
}
#[test]
fn compaction_repairs_every_partition_watermark_in_the_snapshot() {
let dir = tempdir().unwrap();
let snapshot_path = dir.path().join("notes.idx");
let (mut notes, _) =
Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
for note in seed() {
notes.put(¬e, false).unwrap();
}
notes
.put(&json!({ "id": "n1", "kind": "note", "title": "Rewritten" }), false)
.unwrap();
notes.delete("r1", false).unwrap();
notes.snapshot().unwrap();
notes.compact().unwrap();
let snapshot = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
for part in snapshot.header.parts.unwrap() {
let on_disk = fs::metadata(dir.path().join("notes").join(format!("{}.jsonl", part.part))).unwrap();
assert_eq!(part.watermark, on_disk.len(), "partition \"{}\" watermark", part.part);
assert_eq!(part.dead_bytes, 0, "partition \"{}\" dead bytes", part.part);
}
assert_eq!(snapshot.header.watermark, notes.logs.total_size());
let (reopened, outcome) =
Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
assert_eq!(outcome, RestoreOutcome::Restored);
assert_eq!(reopened.registry.records(), 3);
assert_eq!(
reopened.get("n1").unwrap().unwrap().get("title"),
Some(&json!("Rewritten"))
);
assert_eq!(reopened.get("r1").unwrap(), None);
}
#[test]
fn restores_the_registry_from_a_snapshot_that_covers_every_partition() {
let dir = tempdir().unwrap();
let snapshot_path = dir.path().join("notes.idx");
{
let (mut notes, _) =
Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0)
.unwrap();
for note in seed() {
notes.put(¬e, false).unwrap();
}
notes.snapshot().unwrap();
}
let (notes, outcome) =
Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
assert_eq!(outcome, RestoreOutcome::Restored);
assert_eq!(notes.registry.records(), 4);
assert_eq!(
notes.get("a1").unwrap().unwrap().get("title"),
Some(&json!("Backend decision"))
);
}
#[test]
fn rebuilds_when_one_partition_grew_after_the_snapshot_was_taken() {
let dir = tempdir().unwrap();
let snapshot_path = dir.path().join("notes.idx");
{
let (mut notes, _) =
Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0)
.unwrap();
for note in seed() {
notes.put(¬e, false).unwrap();
}
notes.snapshot().unwrap();
notes
.put(
&json!({ "id": "n3", "kind": "note", "title": "after the snapshot" }),
false,
)
.unwrap();
}
let (notes, outcome) =
Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
assert_eq!(outcome, RestoreOutcome::Rebuilt);
assert_eq!(notes.registry.records(), 5);
assert_eq!(
notes.get("n3").unwrap().unwrap().get("title"),
Some(&json!("after the snapshot"))
);
}
#[test]
fn holds_uniqueness_across_partitions_not_just_inside_one() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
notes
.put(
&json!({ "id": "n1", "kind": "note", "title": "first", "slug": "shared" }),
false,
)
.unwrap();
let error = notes
.put(
&json!({ "id": "a1", "kind": "adr", "title": "second", "slug": "shared" }),
false,
)
.unwrap_err();
assert!(matches!(error, TableError::Unique(_)));
}
#[test]
fn refuses_a_partition_value_that_cannot_name_a_file() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
for kind in ["../escape", "Upper", "has space", ""] {
let error = notes
.put(&json!({ "id": "x", "kind": kind, "title": "nope" }), false)
.unwrap_err();
assert!(
matches!(error, TableError::Partition(_)),
"kind {kind:?} must be refused"
);
}
assert!(!dir.path().join("notes").exists());
}
#[test]
fn refuses_to_move_a_record_between_partitions() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
notes
.put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
.unwrap();
let put_error = notes
.put(&json!({ "id": "n1", "kind": "adr", "title": "moved" }), false)
.unwrap_err();
assert!(matches!(put_error, TableError::Partition(_)));
let patch_error = notes.patch("n1", &json!({ "kind": "adr" }), false).unwrap_err();
assert!(matches!(patch_error, TableError::Partition(_)));
assert_eq!(notes.get("n1").unwrap().unwrap().get("kind"), Some(&json!("note")));
assert!(!dir.path().join("notes/adr.jsonl").exists());
}
#[test]
fn refuses_a_staged_write_that_would_move_a_key_the_window_already_placed() {
let dir = tempdir().unwrap();
let mut table = open_notes(dir.path());
table
.stage_put(
&json!({ "id": "n1", "kind": "note", "title": "Backend", "slug": "n1" }),
false,
)
.unwrap();
let refused = table
.stage_put(
&json!({ "id": "n1", "kind": "adr", "title": "Backend", "slug": "n1" }),
true,
)
.unwrap_err();
assert!(matches!(refused, TableError::Partition(_)));
assert!(
refused.to_string().contains("already lives in partition \"note\""),
"the partition came from the window, which the registry knows nothing about: {refused}"
);
}
#[test]
fn rewrites_a_record_inside_its_own_partition() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
notes
.put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
.unwrap();
notes.patch("n1", &json!({ "title": "second" }), false).unwrap();
assert_eq!(notes.get("n1").unwrap().unwrap().get("title"), Some(&json!("second")));
assert_eq!(partition_lines(dir.path(), "note").len(), 2);
}
#[test]
fn puts_a_tombstone_in_the_partition_the_record_lived_in() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
notes
.put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
.unwrap();
notes
.put(&json!({ "id": "a1", "kind": "adr", "title": "kept" }), false)
.unwrap();
assert!(notes.delete("n1", false).unwrap());
assert!(!notes.delete("nope", false).unwrap());
let ops: Vec<Value> = partition_lines(dir.path(), "note")
.iter()
.map(|line| line.get("_op").unwrap().clone())
.collect();
assert_eq!(ops, vec![json!("put"), json!("del")]);
assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
}
#[test]
fn lands_a_unit_of_work_that_spans_two_partitions_of_one_table() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
let prepared = notes
.prepare_txn_ops(&[
TxnOp::Put(json!({ "id": "n1", "kind": "note", "title": "from unit" })),
TxnOp::Put(json!({ "id": "a1", "kind": "adr", "title": "from unit too" })),
])
.unwrap();
assert_eq!(prepared.len(), 2);
notes.apply_txn_entries(prepared).unwrap();
assert_eq!(
notes.get("n1").unwrap().unwrap().get("title"),
Some(&json!("from unit"))
);
assert_eq!(
notes.get("a1").unwrap().unwrap().get("title"),
Some(&json!("from unit too"))
);
assert_eq!(partition_lines(dir.path(), "note").len(), 1);
assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
}
#[test]
fn refuses_a_unit_of_work_that_would_move_a_record_between_partitions() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
notes
.put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
.unwrap();
let outcome = notes.prepare_txn_ops(&[TxnOp::Put(json!({ "id": "n1", "kind": "adr", "title": "moved" }))]);
assert!(matches!(outcome, Err(TableError::Partition(_))));
assert_eq!(notes.get("n1").unwrap().unwrap().get("kind"), Some(&json!("note")));
}
#[test]
fn compacts_one_partition_and_leaves_the_others_byte_for_byte() {
let dir = tempdir().unwrap();
let mut notes = open_notes(dir.path());
notes
.put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
.unwrap();
notes
.put(&json!({ "id": "a1", "kind": "adr", "title": "cold" }), false)
.unwrap();
for round in 0..8 {
notes
.patch("n1", &json!({ "title": format!("round {round}") }), false)
.unwrap();
}
let cold_before = fs::read_to_string(dir.path().join("notes/adr.jsonl")).unwrap();
assert_eq!(partition_lines(dir.path(), "note").len(), 9);
let result = notes.compact().unwrap();
assert_eq!(result.records, 2);
assert_eq!(partition_lines(dir.path(), "note").len(), 1);
assert_eq!(
fs::read_to_string(dir.path().join("notes/adr.jsonl")).unwrap(),
cold_before
);
assert_eq!(notes.get("n1").unwrap().unwrap().get("title"), Some(&json!("round 7")));
assert_eq!(notes.get("a1").unwrap().unwrap().get("title"), Some(&json!("cold")));
}
fn seeded_flat(dir: &std::path::Path) -> Table {
let mut notes = Table::open(notes_schema(false), log_path(dir)).unwrap();
for note in seed() {
notes.put(¬e, false).unwrap();
}
notes.delete("n2", false).unwrap();
notes
}
#[test]
fn moves_the_live_records_into_one_file_per_partition_when_the_schema_starts_partitioning() {
let dir = tempdir().unwrap();
drop(seeded_flat(dir.path()));
let notes = open_notes(dir.path());
assert_eq!(notes.registry.records(), 3);
assert_eq!(notes.get("n2").unwrap(), None);
assert_eq!(
notes.get("a1").unwrap().unwrap().get("title"),
Some(&json!("Backend decision"))
);
assert!(!log_path(dir.path()).exists());
assert_eq!(partition_lines(dir.path(), "note").len(), 1);
assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
assert_eq!(partition_lines(dir.path(), "rule").len(), 1);
}
fn open_notes_readonly(dir: &std::path::Path) -> (Table, RestoreOutcome) {
Table::open_with_snapshot_and_declared_indexes(
notes_schema(true),
log_path(dir),
dir.join("notes.idx"),
"hash",
0,
true,
&[],
)
.unwrap()
}
#[test]
fn a_readonly_open_reads_the_flat_log_the_move_has_not_touched_yet() {
let dir = tempdir().unwrap();
drop(seeded_flat(dir.path()));
let before = fs::read_to_string(log_path(dir.path())).unwrap();
let (notes, _) = open_notes_readonly(dir.path());
assert_eq!(notes.registry.records(), 3);
assert_eq!(notes.get("n2").unwrap(), None);
assert_eq!(
notes.get("a1").unwrap().unwrap().get("title"),
Some(&json!("Backend decision"))
);
assert_eq!(
fs::read_to_string(log_path(dir.path())).unwrap(),
before,
"a readonly open must leave the flat log byte for byte as it found it"
);
assert!(
!dir.path().join("notes").exists(),
"a readonly open must not create the partition directory"
);
}
#[test]
fn a_readonly_open_of_the_unmoved_flat_log_writes_no_index_snapshot() {
let dir = tempdir().unwrap();
drop(seeded_flat(dir.path()));
let (notes, outcome) = open_notes_readonly(dir.path());
assert_eq!(outcome, RestoreOutcome::Rebuilt);
assert_eq!(notes.registry.records(), 3);
assert!(
!dir.path().join("notes.idx").exists(),
"a readonly open must not write the index snapshot"
);
}
#[test]
fn a_writer_still_finishes_the_move_a_readonly_open_left_undone() {
let dir = tempdir().unwrap();
drop(seeded_flat(dir.path()));
drop(open_notes_readonly(dir.path()));
let notes = open_notes(dir.path());
assert_eq!(notes.registry.records(), 3);
assert_eq!(
notes.get("a1").unwrap().unwrap().get("title"),
Some(&json!("Backend decision"))
);
assert!(!log_path(dir.path()).exists());
assert_eq!(partition_lines(dir.path(), "note").len(), 1);
assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
assert_eq!(partition_lines(dir.path(), "rule").len(), 1);
}
#[test]
fn a_readonly_open_reads_the_partitions_once_the_move_has_run() {
let dir = tempdir().unwrap();
drop(seeded_flat(dir.path()));
drop(open_notes(dir.path()));
let (notes, _) = open_notes_readonly(dir.path());
assert!(notes.logs.is_partitioned());
assert_eq!(notes.registry.records(), 3);
assert_eq!(notes.get("r1").unwrap().unwrap().get("kind"), Some(&json!("rule")));
}
#[test]
fn keeps_full_text_results_identical_to_the_unpartitioned_table() {
let dir = tempdir().unwrap();
let flat_hits = {
let flat = seeded_flat(dir.path());
let spec = QueryBuilder::new().search("silnik", SearchMode::All).spec();
let mut hits = flat.query(&spec).unwrap().keys;
hits.sort();
hits
};
let notes = open_notes(dir.path());
let spec = QueryBuilder::new().search("silnik", SearchMode::All).spec();
let mut partitioned_hits = notes.query(&spec).unwrap().keys;
partitioned_hits.sort();
assert!(!flat_hits.is_empty());
assert_eq!(partitioned_hits, flat_hits);
}
#[test]
fn writes_and_reads_further_records_after_the_move() {
let dir = tempdir().unwrap();
drop(seeded_flat(dir.path()));
let mut notes = open_notes(dir.path());
notes
.put(
&json!({ "id": "n3", "kind": "note", "title": "written after the move" }),
false,
)
.unwrap();
assert!(notes.delete("a1", false).unwrap());
assert_eq!(
notes.get("n3").unwrap().unwrap().get("title"),
Some(&json!("written after the move"))
);
assert_eq!(notes.get("a1").unwrap(), None);
assert_eq!(partition_lines(dir.path(), "note").len(), 2);
assert_eq!(partition_lines(dir.path(), "adr").len(), 2);
}
#[test]
fn an_unpartitioned_table_keeps_writing_the_single_flat_log() {
let dir = tempdir().unwrap();
let mut notes = Table::open(notes_schema(false), log_path(dir.path())).unwrap();
for note in seed() {
notes.put(¬e, false).unwrap();
}
assert!(log_path(dir.path()).is_file());
assert!(!dir.path().join("notes").exists());
assert_eq!(
fs::read_to_string(log_path(dir.path())).unwrap().trim().lines().count(),
4
);
assert_eq!(notes.get("n1").unwrap().unwrap().get("slug"), Some(&json!("n1")));
}
}
mod read_cache {
use std::io::Write as _;
use super::*;
const LARGE_BYTES: usize = 512;
fn policy(entries: usize, large_entries: usize) -> CachePolicy {
CachePolicy::Enabled {
entries,
large_entries,
large_bytes: LARGE_BYTES,
}
}
fn open_cached(cache: CachePolicy) -> (tempfile::TempDir, Table, PathBuf) {
let (dir, mut table) = open_table();
let path = dir.path().join("users.jsonl");
table.set_cache(cache);
(dir, table, path)
}
fn seed(table: &mut Table, key: &str, name: &str) {
table
.put(&json!({ "id": key, "name": name, "age": 30 }), false)
.unwrap();
}
fn name_of(table: &Table, key: &str) -> Value {
table.get(key).unwrap().unwrap().get("name").unwrap().clone()
}
fn scribble(path: &Path) {
let length = fs::metadata(path).unwrap().len() as usize;
let mut file = fs::OpenOptions::new().write(true).open(path).unwrap();
file.write_all(&vec![b'#'; length]).unwrap();
}
#[test]
fn a_second_get_of_a_cached_key_answers_without_touching_the_log_again() {
let (_dir, mut table, path) = open_cached(policy(4, 1));
seed(&mut table, "u1", "Ania");
assert_eq!(name_of(&table, "u1"), json!("Ania"));
scribble(&path);
assert_eq!(name_of(&table, "u1"), json!("Ania"));
}
#[test]
fn without_a_cache_the_same_second_get_goes_back_to_the_log() {
let (dir, mut table) = open_table();
let path = dir.path().join("users.jsonl");
seed(&mut table, "u1", "Ania");
assert_eq!(name_of(&table, "u1"), json!("Ania"));
scribble(&path);
assert!(
matches!(table.get("u1"), Err(TableError::Io(_))),
"an uncached read parses the log line every time"
);
}
#[test]
fn a_table_nobody_configured_caches_nothing_and_reads_exactly_as_before() {
let (_dir, mut table) = open_table();
assert_eq!(table.cache_policy(), CachePolicy::Disabled);
seed(&mut table, "u1", "Ania");
assert_eq!(name_of(&table, "u1"), json!("Ania"));
table.patch("u1", &json!({ "name": "Zosia" }), false).unwrap();
assert_eq!(name_of(&table, "u1"), json!("Zosia"));
assert!(table.delete("u1", false).unwrap());
assert_eq!(table.get("u1").unwrap(), None);
assert_eq!(table.cache.len(), (0, 0));
}
#[test]
fn a_put_over_a_cached_key_is_what_the_next_get_answers() {
let (_dir, mut table, _path) = open_cached(policy(4, 1));
seed(&mut table, "u1", "Ania");
assert_eq!(name_of(&table, "u1"), json!("Ania"));
seed(&mut table, "u1", "Zosia");
assert_eq!(name_of(&table, "u1"), json!("Zosia"));
}
#[test]
fn a_patch_over_a_cached_key_is_what_the_next_get_answers() {
let (_dir, mut table, _path) = open_cached(policy(4, 1));
seed(&mut table, "u1", "Ania");
assert_eq!(name_of(&table, "u1"), json!("Ania"));
table.patch("u1", &json!({ "age": 41 }), false).unwrap();
assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(41)));
}
#[test]
fn a_delete_of_a_cached_key_leaves_nothing_for_the_next_get() {
let (_dir, mut table, _path) = open_cached(policy(4, 1));
seed(&mut table, "u1", "Ania");
assert_eq!(name_of(&table, "u1"), json!("Ania"));
assert!(table.delete("u1", false).unwrap());
assert_eq!(table.get("u1").unwrap(), None);
assert_eq!(table.cache.len(), (0, 0));
}
#[test]
fn the_limit_evicts_the_least_recently_read_key_and_it_still_reads_off_the_log() {
let (_dir, mut table, _path) = open_cached(policy(2, 1));
seed(&mut table, "u1", "Ania");
seed(&mut table, "u2", "Piotr");
seed(&mut table, "u3", "Zosia");
assert_eq!(name_of(&table, "u2"), json!("Piotr"));
assert_eq!(name_of(&table, "u1"), json!("Ania"));
assert_eq!(name_of(&table, "u3"), json!("Zosia"));
assert_eq!(table.cache.len(), (2, 0));
assert_eq!(
name_of(&table, "u2"),
json!("Piotr"),
"the evicted key falls back to the log"
);
}
#[test]
fn an_evicted_key_reaches_for_the_log_while_the_two_kept_ones_do_not() {
let (_dir, mut table, path) = open_cached(policy(2, 1));
seed(&mut table, "u1", "Ania");
seed(&mut table, "u2", "Piotr");
seed(&mut table, "u3", "Zosia");
table.get("u2").unwrap();
table.get("u1").unwrap();
table.get("u3").unwrap();
scribble(&path);
assert!(matches!(table.get("u2"), Err(TableError::Io(_))));
assert_eq!(name_of(&table, "u1"), json!("Ania"));
assert_eq!(name_of(&table, "u3"), json!("Zosia"));
}
#[test]
fn large_documents_share_their_own_smaller_tier_and_leave_the_small_ones_alone() {
let (_dir, mut table, path) = open_cached(policy(2, 1));
let long = "x".repeat(LARGE_BYTES);
seed(&mut table, "u1", "Ania");
seed(&mut table, "u2", "Piotr");
seed(&mut table, "d1", &long);
seed(&mut table, "d2", &long);
table.get("u1").unwrap();
table.get("u2").unwrap();
table.get("d1").unwrap();
table.get("d2").unwrap();
assert_eq!(
table.cache.len(),
(2, 1),
"two large documents share one slot; the small ones keep both of theirs"
);
scribble(&path);
assert_eq!(name_of(&table, "u1"), json!("Ania"));
assert_eq!(name_of(&table, "u2"), json!("Piotr"));
assert_eq!(name_of(&table, "d2"), json!(long));
assert!(
matches!(table.get("d1"), Err(TableError::Io(_))),
"the older large document was the one evicted"
);
}
#[test]
fn a_refresh_that_catches_a_foreign_write_drops_the_document_it_cached() {
let (_dir, mut writer, mut follower) = two_handles();
follower.set_cache(policy(4, 1));
writer
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
follower.refresh().unwrap();
assert_eq!(name_of(&follower, "u1"), json!("Ania"));
writer
.put(&json!({ "id": "u1", "name": "Zosia", "age": 30 }), false)
.unwrap();
assert_eq!(follower.refresh().unwrap(), RefreshOutcome::CaughtUp { entries: 1 });
assert_eq!(name_of(&follower, "u1"), json!("Zosia"));
}
#[test]
fn a_following_read_of_a_cached_key_sees_the_foreign_write_it_caught_up_with() {
let (_dir, mut writer, mut follower) = two_handles();
follower.set_cache(policy(4, 1));
follower.set_follow(Some(FollowPolicy {
interval: Duration::ZERO,
}));
writer
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
assert_eq!(
follower.get_following("u1").unwrap().unwrap().get("name"),
Some(&json!("Ania"))
);
writer
.put(&json!({ "id": "u1", "name": "Zosia", "age": 30 }), false)
.unwrap();
assert_eq!(
follower.get_following("u1").unwrap().unwrap().get("name"),
Some(&json!("Zosia"))
);
}
#[test]
fn a_reload_after_a_foreign_compaction_throws_the_whole_cache_away() {
let (_dir, mut writer, mut follower) = two_handles();
follower.set_cache(policy(4, 1));
writer
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
writer
.put(&json!({ "id": "u2", "name": "Piotr", "age": 41 }), false)
.unwrap();
follower.refresh().unwrap();
assert_eq!(name_of(&follower, "u1"), json!("Ania"));
assert_eq!(name_of(&follower, "u2"), json!("Piotr"));
writer
.put(&json!({ "id": "u1", "name": "Zosia", "age": 30 }), false)
.unwrap();
writer.compact().unwrap();
assert_eq!(follower.refresh().unwrap(), RefreshOutcome::Reloaded);
assert_eq!(name_of(&follower, "u1"), json!("Zosia"));
assert_eq!(name_of(&follower, "u2"), json!("Piotr"));
}
#[test]
fn compacting_this_handles_own_log_leaves_nothing_cached_behind() {
let (_dir, mut table, _path) = open_cached(policy(4, 1));
seed(&mut table, "u1", "Ania");
seed(&mut table, "u2", "Piotr");
seed(&mut table, "u1", "Zosia");
table.get("u1").unwrap();
table.get("u2").unwrap();
table.compact().unwrap();
assert_eq!(
table.cache.len(),
(0, 0),
"every offset the cache was filled under moved"
);
assert_eq!(name_of(&table, "u1"), json!("Zosia"));
assert_eq!(name_of(&table, "u2"), json!("Piotr"));
}
#[test]
fn a_unit_of_work_entry_applied_over_a_cached_key_invalidates_it() {
let (_dir, mut table, _path) = open_cached(policy(4, 1));
seed(&mut table, "u1", "Ania");
assert_eq!(name_of(&table, "u1"), json!("Ania"));
let prepared = table
.prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Zosia", "age": 30 }))])
.unwrap();
table.apply_txn_entries(prepared).unwrap();
assert_eq!(name_of(&table, "u1"), json!("Zosia"));
}
#[test]
fn a_recovered_journal_line_over_a_cached_key_invalidates_it() {
let (_dir, mut writer, mut follower) = two_handles();
follower.set_cache(policy(4, 1));
writer
.put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
.unwrap();
follower.refresh().unwrap();
assert_eq!(name_of(&follower, "u1"), json!("Ania"));
let prepared = writer
.prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Zosia", "age": 30 }))])
.unwrap();
let lines: Vec<(u64, String)> = prepared
.iter()
.map(|entry| (entry.seq(), entry.line().to_string()))
.collect();
assert_eq!(follower.recover_txn_lines(&lines).unwrap(), 1);
assert_eq!(name_of(&follower, "u1"), json!("Zosia"));
}
#[test]
fn re_arming_the_cache_starts_it_cold() {
let (_dir, mut table, path) = open_cached(policy(4, 1));
seed(&mut table, "u1", "Ania");
table.get("u1").unwrap();
table.set_cache(policy(4, 1));
scribble(&path);
assert!(matches!(table.get("u1"), Err(TableError::Io(_))));
}
}
}