Skip to main content

hayson_engine/table/
handle.rs

1use std::borrow::Cow;
2use std::collections::{BTreeMap, HashMap, HashSet};
3use std::fmt;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::time::{Duration, Instant};
8
9use serde_json::Value;
10
11use crate::catalog::{
12    CatalogCompaction, CatalogSyncMode, CatalogValidation, ExpandedIndex, LinkParsing, TriggerEntry, TriggerEvent,
13};
14use crate::checkpoint::{
15    remove_snapshot, restore_or_rebuild_set_with_declared, write_snapshot_set, RestoreOutcome, Restored,
16};
17use crate::crypto::{
18    assert_carried_seals, cypher_fields_of, envelope_key_id, open_record, seal_record, FieldCipher, MissingKeyPolicy,
19};
20use crate::errors::{
21    HaysonCipherError, HaysonNotFoundError, HaysonPartitionError, HaysonPendingConflictError,
22    HaysonRelationRestrictError, HaysonRelationViolationError, HaysonSchemaError, HaysonUniqueViolationError,
23    HaysonValidationError,
24};
25use crate::query::aggregate::{
26    execute_aggregate, execute_grouped_aggregate, AggregateResult, AggregateSpec, GroupedAggregateResult,
27};
28use crate::query::builder::QueryBuilder;
29use crate::query::planner::{
30    execute_access, execute_query, plan_access_overlaid, LogSetRecordSource, QueryOverlay, QueryResult, QuerySpec,
31    RecordSource, RecordSourceError,
32};
33use crate::queue::write_queue::{assert_partition_stable, assert_unique_in_unit, UnitShadow};
34use crate::queue::{StageError, WriteOutcome, WriteQueue};
35use crate::registry::fulltext_index::CorpusDelta;
36use crate::registry::table_registry::TableRegistry;
37use crate::schema::{
38    broken_links, is_partitioned, partition_of, physical_path_of, primary_key_of, undeclared_fields, validate_record,
39    without_undeclared, FieldType, HaysonRecord, TableSchema, ValidateOptions, UNPARTITIONED,
40};
41use crate::storage::{
42    ensure_partitioned, parse_entry, partition_names_in, serialize_entry, LogEntry, LogMeta, LogOp, LogSet, ScanResult,
43    Slot,
44};
45
46use super::cache::{CachePolicy, RecordCache};
47use super::trigger::{effect_document, matching, TriggerFiring};
48
49#[derive(Debug)]
50pub enum TableError {
51    Validation(HaysonValidationError),
52    Unique(HaysonUniqueViolationError),
53    Conflict(HaysonPendingConflictError),
54    NotFound(HaysonNotFoundError),
55    Schema(HaysonSchemaError),
56    Partition(HaysonPartitionError),
57    Relation(HaysonRelationViolationError),
58    RelationRestrict(HaysonRelationRestrictError),
59    /// Boxed — `HaysonCipherError` is the widest of these variants, and only the `cypher`-fields
60    /// path ever builds one, so nothing else should pay for its size at rest.
61    Cipher(Box<HaysonCipherError>),
62    Io(io::Error),
63}
64
65impl fmt::Display for TableError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            TableError::Validation(e) => e.fmt(f),
69            TableError::Unique(e) => e.fmt(f),
70            TableError::Conflict(e) => e.fmt(f),
71            TableError::NotFound(e) => e.fmt(f),
72            TableError::Schema(e) => e.fmt(f),
73            TableError::Partition(e) => e.fmt(f),
74            TableError::Relation(e) => e.fmt(f),
75            TableError::RelationRestrict(e) => e.fmt(f),
76            TableError::Cipher(e) => e.fmt(f),
77            TableError::Io(e) => e.fmt(f),
78        }
79    }
80}
81
82impl std::error::Error for TableError {}
83
84impl From<RecordSourceError> for TableError {
85    fn from(value: RecordSourceError) -> Self {
86        match value {
87            RecordSourceError::Cipher(error) => TableError::Cipher(error),
88            RecordSourceError::Io(error) => TableError::Io(error),
89        }
90    }
91}
92
93impl From<StageError> for TableError {
94    fn from(value: StageError) -> Self {
95        match value {
96            StageError::Validation(e) => TableError::Validation(e),
97            StageError::Unique(e) => TableError::Unique(e),
98            StageError::Conflict(e) => TableError::Conflict(e),
99            StageError::Partition(e) => TableError::Partition(e),
100        }
101    }
102}
103
104impl From<io::Error> for TableError {
105    fn from(value: io::Error) -> Self {
106        TableError::Io(value)
107    }
108}
109
110impl From<HaysonValidationError> for TableError {
111    fn from(value: HaysonValidationError) -> Self {
112        TableError::Validation(value)
113    }
114}
115
116impl From<HaysonPendingConflictError> for TableError {
117    fn from(value: HaysonPendingConflictError) -> Self {
118        TableError::Conflict(value)
119    }
120}
121
122impl From<HaysonUniqueViolationError> for TableError {
123    fn from(value: HaysonUniqueViolationError) -> Self {
124        TableError::Unique(value)
125    }
126}
127
128impl From<HaysonPartitionError> for TableError {
129    fn from(value: HaysonPartitionError) -> Self {
130        TableError::Partition(value)
131    }
132}
133
134impl From<HaysonCipherError> for TableError {
135    fn from(value: HaysonCipherError) -> Self {
136        TableError::Cipher(Box::new(value))
137    }
138}
139
140/// Lets the `io::Result` paths that now flush first (`compact`, `snapshot`, `query_following` and
141/// friends) carry a flush failure without changing their signature. Only the `Io` variant survives
142/// intact; the rest cannot be raised by a flush of writes that already passed staging, apart from a
143/// trigger effect the schema refuses, which arrives here as its message.
144impl From<TableError> for io::Error {
145    fn from(value: TableError) -> Self {
146        match value {
147            TableError::Io(error) => error,
148            other => io::Error::other(other.to_string()),
149        }
150    }
151}
152
153impl From<crate::queue::CommitError> for TableError {
154    fn from(value: crate::queue::CommitError) -> Self {
155        match value {
156            crate::queue::CommitError::Io(error) => TableError::Io(error),
157            crate::queue::CommitError::Cipher(error) => TableError::Cipher(Box::new(error)),
158        }
159    }
160}
161
162/// One markdown link of a record that resolves to nothing — the field the link was found in
163/// against the virtual path it aimed at, kept apart rather than formatted into a sentence, so a
164/// caller can group by field or retry the target without parsing text back out.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct BrokenLink {
167    pub field: String,
168    pub path: String,
169}
170
171/// The result of [`Table::put_with_warnings`]/[`Table::patch_with_warnings`] — the written record
172/// plus diagnostics that do not block the write (currently only broken markdown links found while
173/// [`Table::link_parsing`] is `Eager`; empty for a `Lazy` table, which surfaces them only on
174/// request via [`Table::broken_links_of`]). [`Table::put`]/[`Table::patch`] stay untouched and
175/// keep returning a bare `HaysonRecord` on a separate method, rather than changing their own
176/// signature, so no existing caller has to start handling warnings it never asked for.
177///
178/// `warnings` carries broken links and nothing else: the two other diagnostics this table answers
179/// for — a dropped undeclared field and a write touching a `deprecated` one — have channels of
180/// their own ([`Table::dropped_field_warnings`], [`Table::deprecated_field_warnings`]) because
181/// neither can be recomputed from the stored record. That is why this field could take the
182/// structured [`BrokenLink`] rather than staying a bag of formatted strings.
183pub struct PutOutcome {
184    pub record: HaysonRecord,
185    pub warnings: Vec<BrokenLink>,
186}
187
188impl PutOutcome {
189    fn into_record(self) -> HaysonRecord {
190        self.record
191    }
192}
193
194impl From<PutOutcome> for HaysonRecord {
195    fn from(outcome: PutOutcome) -> Self {
196        outcome.record
197    }
198}
199
200/// Which of the three things [`Table::rotate_cypher_keys`] can do to a record it read applies.
201enum RotationVerdict {
202    /// Every sealed field already names the current write key.
203    Current,
204    /// At least one field is on another key, or in the clear, and the keyring holds what it needs.
205    Rotate,
206    /// A field names this key id, which the keyring does not hold.
207    Missing(String),
208}
209
210/// What one run of [`Table::rotate_cypher_keys`] did. Counts records, not fields: a record with
211/// two `cypher` fields on a retired key is rotated once and counted once.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
213pub struct RotationResult {
214    /// Records read, decrypted and written back under the current write key.
215    pub rotated: usize,
216    /// Records every `cypher` field of which was already sealed under the current write key —
217    /// left exactly as they were, which is what makes a second run free.
218    pub already_current: usize,
219    /// Records left alone because a field of theirs names a key the keyring does not hold. Only
220    /// ever non-zero under [`MissingKeyPolicy::Omit`]/[`MissingKeyPolicy::Raw`]; the default
221    /// `Throw` refuses the whole run instead — see [`Table::rotate_cypher_keys`].
222    pub skipped: usize,
223}
224
225/// What [`Table::refresh`] found on disk since this handle last looked. Modelled on
226/// [`RestoreOutcome`], which reports the same kind of thing about an open: not whether it worked,
227/// but which of its two ways it had to work.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum RefreshOutcome {
230    /// Every log file this handle holds is exactly where it left it — one `stat` per partition was
231    /// read and nothing else.
232    Unchanged,
233    /// Lines another writer appended past this handle's watermark were scanned and applied on top
234    /// of the registry it already had.
235    CaughtUp { entries: usize },
236    /// A log file was replaced underneath this handle — what the rewrite half of a foreign
237    /// [`Table::compact`] does — so the registry was thrown away and rebuilt from the files as they
238    /// are now. Slots recorded before such a rewrite name offsets that no longer mean anything,
239    /// which is exactly why this case cannot be served by scanning onwards from a watermark.
240    Reloaded,
241}
242
243impl RefreshOutcome {
244    /// Whether anything changed at all — the boolean the TS `Table.refresh()` answers with.
245    pub fn changed(self) -> bool {
246        self != RefreshOutcome::Unchanged
247    }
248}
249
250/// How often a handle catches up with foreign writes on its own, before serving a read — the port
251/// of the TS `FollowPolicy` (`packages/hayson/src/table/table.ts`).
252///
253/// It exists for a readonly handle, which never writes and so has no other occasion to notice what
254/// another process committed. A writable handle already sees its own writes and is the only writer
255/// the directory allows, so it follows nothing by default.
256///
257/// `interval` is a floor on how often the check is paid for, not a schedule: nothing runs in the
258/// background, and a handle nobody reads from never looks at the disk at all. `Duration::ZERO` —
259/// the default a readonly open resolves to, matching the TS `DEFAULT_FOLLOW_INTERVAL_MS` of `0` —
260/// means every read checks, which costs one `stat` per log file when nothing changed.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub struct FollowPolicy {
263    pub interval: Duration,
264}
265
266impl Default for FollowPolicy {
267    fn default() -> Self {
268        FollowPolicy {
269            interval: Duration::ZERO,
270        }
271    }
272}
273
274/// Where a key stands between [`Table::stage_put`]/[`Table::stage_patch`]/[`Table::stage_delete`]
275/// and the [`Table::flush`] that commits them — one entry per key, holding the write that would win
276/// if the flush ran now.
277///
278/// `old` is the committed record the key held when the *first* write of this staging window touched
279/// it, not the one before the latest re-stage: the registry has already moved on by the time the
280/// flush fires triggers, so the only chance to read it is here.
281struct StagedWrite {
282    /// Staging order, so a flush fires triggers in the order the writes were made rather than in
283    /// whatever order the map iterates. A key staged again keeps the id of its first write.
284    id: u64,
285    /// `None` is a staged delete — a key whose reads answer "gone" before the tombstone is written.
286    record: Option<HaysonRecord>,
287    old: Option<HaysonRecord>,
288    /// `false` for the buffer-replay path, which must not fire triggers a second time — see
289    /// [`Table::put_from_buffer`].
290    live: bool,
291}
292
293/// The counters behind [`Table::stats`], in the shape of the TS `TableStats`.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub struct TableStats {
296    pub records: usize,
297    pub live_bytes: u64,
298    pub dead_bytes: u64,
299    pub log_bytes: u64,
300    /// Writes staged but not yet appended — `0` unless a caller staged through
301    /// [`Table::stage_put`] and friends without flushing yet, since `put`/`patch`/`delete` stage and
302    /// flush inside the same call.
303    pub queue_depth: usize,
304    /// The log's generation: how many times it has been rewritten under this registry, counting
305    /// both this handle's own [`Table::compact`] and the rebuilds [`Table::refresh`] does after
306    /// another process compacted underneath it. Persisted in the snapshot header and carried over
307    /// on the next open, so it survives a restart rather than starting from `0` again.
308    pub gen: u64,
309    pub seq: u64,
310}
311
312/// One log line naming a record, as [`Table::history`] read it off the file — the version itself,
313/// not the record as the live schema would project it.
314#[derive(Debug, Clone, PartialEq)]
315pub struct HistoryEntry {
316    pub seq: u64,
317    pub ts: i64,
318    pub op: LogOp,
319    /// The partition file this line was found in — [`UNPARTITIONED`] on a table with no
320    /// `partitionBy`, and on one whose declared split has not run yet.
321    pub partition: String,
322    pub offset: u64,
323    pub length: u64,
324    /// The record exactly as the line stores it: `None` for a tombstone, and for a `put` still
325    /// carrying whatever the schema of the day wrote — a sealed `cypher` envelope is not opened and
326    /// a since-dropped field is not projected away.
327    pub document: Option<HaysonRecord>,
328}
329
330/// Every version of one key, with the files that were read to find them — the answer of
331/// [`Table::history`].
332#[derive(Debug, Clone, PartialEq)]
333pub struct TableHistory {
334    /// Oldest first, ordered by `seq`, so a key that moved between partitions still reads as one
335    /// history rather than as one list per file.
336    pub entries: Vec<HistoryEntry>,
337    /// The log files scanned, in partition-name order. Reported because the scan is the cost of the
338    /// call, and on a partitioned table it says which file each version came from.
339    pub scanned: Vec<PathBuf>,
340}
341
342fn value_as_key(value: &Value) -> String {
343    match value {
344        Value::String(s) => s.clone(),
345        other => other.to_string(),
346    }
347}
348
349/// Drops from a record every field the schema does not declare, so a `dropField` (and a log written
350/// by an older schema) reads back as the current schema describes it. The log itself is never
351/// rewritten — `dropField` leaves it byte for byte — so this is the only place the dropped field
352/// stops existing for a reader.
353///
354/// Returns the record untouched when nothing has to go, which is every record of a table whose log
355/// was only ever written under the schema it has now.
356fn project_onto_schema(schema: &TableSchema, record: HaysonRecord) -> HaysonRecord {
357    if record.keys().all(|field| schema.fields.contains_key(field)) {
358        return record;
359    }
360    record
361        .into_iter()
362        .filter(|(field, _)| schema.fields.contains_key(field))
363        .collect()
364}
365
366/// Default image-bytes directory for a table with no `options.imageDir` override: a sibling of its
367/// log file, named after it, so opening `<dir>/<table>.jsonl` gives `<dir>/<table>.images/`.
368fn default_image_dir(log_path: &Path, table: &str) -> PathBuf {
369    let images = format!("{table}.images");
370    match log_path.parent() {
371        Some(parent) => parent.join(images),
372        None => PathBuf::from(images),
373    }
374}
375
376/// Ties `TableRegistry` (indexes), `WriteQueue` (validation, uniqueness, staging) and `LogFile`
377/// (persistence) into one table API. Deliberately thin: put/patch/delete all delegate their
378/// validation and unique-checking to `WriteQueue::stage_put`, they do not repeat it here.
379pub struct Table {
380    schema: TableSchema,
381    registry: TableRegistry,
382    logs: LogSet,
383    queue: WriteQueue,
384    /// The writes `queue` holds, keyed by the record they land on, so a read can answer from the
385    /// staging window instead of the log — see [`StagedWrite`]. Emptied by [`Table::flush`].
386    staged: HashMap<String, StagedWrite>,
387    next_op_id: u64,
388    seq: u64,
389    snapshot_path: Option<PathBuf>,
390    schema_hash: String,
391    snapshot_every: u64,
392    since_snapshot: u64,
393    snapshot_current: bool,
394    /// The thresholds [`Table::compact_if_due`] measures each partition against after a write.
395    /// `Disabled` on every constructor, exactly as `snapshot_every` starts at `0`: a raw handle
396    /// rewrites nothing it was not asked to. `Database::open` resolves the real policy from the
397    /// catalog and hands it over through [`Table::set_compaction`].
398    compaction: CatalogCompaction,
399    /// How durable this table's appends are. `Commit` on every constructor — the safe default, and
400    /// what every caller got before `options.sync` was read at all; `Database::open` resolves the
401    /// declared mode from the catalog and hands it over through [`Table::set_sync`].
402    sync: CatalogSyncMode,
403    /// What a write naming a field the schema does not declare gets. `Strict` on every constructor
404    /// — the refusal every caller has always had; `Database::open` resolves the declared mode from
405    /// the table's catalog entry and hands it over through [`Table::set_validation`].
406    validation: CatalogValidation,
407    /// The fields the catalog marks `deprecated`, against their note. Empty on every constructor —
408    /// the schema carries no such mark, so a raw handle has nothing to warn about;
409    /// `Database::open` resolves the set from the table's catalog entry and hands it over through
410    /// [`Table::set_deprecated_fields`].
411    deprecated_fields: BTreeMap<String, String>,
412    triggers: Vec<TriggerEntry>,
413    image_dir: PathBuf,
414    link_parsing: LinkParsing,
415    cipher: Option<FieldCipher>,
416    cypher_fields: Vec<String>,
417    /// Kept past the open it was used for, because [`Table::refresh`] may have to rebuild the
418    /// registry from scratch and a rebuilt one has to carry the same declared indexes as the
419    /// original — dropping them here would silently retire every composite and `where`-scoped
420    /// index the catalog declares the moment another process compacts.
421    declared: Vec<ExpandedIndex>,
422    gen: u64,
423    /// `None` — the default every constructor starts from — is the TS `follow: false`: this handle
424    /// only ever catches up when somebody calls [`Table::refresh`]. `Database::open` sets it.
425    follow: Option<FollowPolicy>,
426    /// When [`Table::follow_if_due`] last passed its interval gate. `None` until the first read, so
427    /// the first one always checks however long the interval is.
428    followed_at: Option<Instant>,
429    /// Parsed documents [`Table::get`] may answer from instead of reading the log again.
430    /// `Disabled` on every constructor, as `compaction` is: a raw handle caches nothing.
431    /// `Database::open` hands over what `OpenOptions::cache` names, through [`Table::set_cache`].
432    cache: RecordCache,
433}
434
435impl Table {
436    /// Opens a table with no paired snapshot file — used for catalog operations (`drop_field`,
437    /// `declare_index`) that reopen a table over its existing log right after rewriting the schema.
438    /// `snapshot()`/`needs_snapshot()` are no-ops on a table opened this way, same as it never
439    /// restores from a snapshot either.
440    pub fn open(schema: TableSchema, path: impl AsRef<Path>) -> io::Result<Self> {
441        Self::open_with_declared_indexes(schema, path, &[])
442    }
443
444    /// Same as [`Table::open`], but the registry also builds every composite or `where`-scoped
445    /// index the catalog declares for this table (`catalog::expand_indexes`) alongside the sugar
446    /// [`Table::open`] derives from the schema alone — called by `Database`, which owns the
447    /// catalog `Table::open` itself has none of.
448    pub fn open_with_declared_indexes(
449        schema: TableSchema,
450        path: impl AsRef<Path>,
451        declared: &[ExpandedIndex],
452    ) -> io::Result<Self> {
453        let image_dir = default_image_dir(path.as_ref(), &schema.table);
454        let cypher_fields = cypher_fields_of(&schema);
455        let mut logs = open_logs(&schema, path.as_ref(), false)?;
456        let registry = TableRegistry::build_from_log_set_with_declared(schema.clone(), &mut logs, declared)?;
457        let seq = registry.max_seq();
458        let queue = WriteQueue::new(schema.table.clone());
459        Ok(Table {
460            schema,
461            registry,
462            logs,
463            queue,
464            staged: HashMap::new(),
465            next_op_id: 0,
466            seq,
467            snapshot_path: None,
468            schema_hash: String::new(),
469            snapshot_every: 0,
470            since_snapshot: 0,
471            snapshot_current: false,
472            compaction: CatalogCompaction::Disabled,
473            sync: CatalogSyncMode::Commit,
474            validation: CatalogValidation::Strict,
475            deprecated_fields: BTreeMap::new(),
476            triggers: Vec::new(),
477            image_dir,
478            link_parsing: LinkParsing::Eager,
479            cipher: None,
480            cypher_fields,
481            declared: declared.to_vec(),
482            gen: 0,
483            follow: None,
484            followed_at: None,
485            cache: RecordCache::new(CachePolicy::Disabled),
486        })
487    }
488
489    /// Opens the log at `path` and restores its registry from `snapshot_path` via
490    /// `checkpoint::restore_or_rebuild` instead of always scanning the whole log — used by
491    /// `database::open()` at startup. Falls back to a full scan (`RestoreOutcome::Rebuilt`) exactly
492    /// as `restore_or_rebuild` does when the snapshot is missing, stale, or paired with a different
493    /// schema.
494    ///
495    /// `snapshot_every` mirrors the TS `snapshotEvery` table option (ADR/0047 line): after this many
496    /// operations have been applied since the last snapshot, the next commit writes a fresh
497    /// `snapshot_path` and resets the counter. `0` disables periodic snapshotting for this table.
498    pub fn open_with_snapshot(
499        schema: TableSchema,
500        path: impl AsRef<Path>,
501        snapshot_path: impl AsRef<Path>,
502        schema_hash: &str,
503        snapshot_every: u64,
504    ) -> io::Result<(Self, RestoreOutcome)> {
505        Self::open_with_snapshot_and_declared_indexes(
506            schema,
507            path,
508            snapshot_path,
509            schema_hash,
510            snapshot_every,
511            false,
512            &[],
513        )
514    }
515
516    /// Same as [`Table::open_with_snapshot`], but both the restored and the rebuilt registry are
517    /// built with `declared` — the catalog's fully expanded index list (`catalog::expand_indexes`)
518    /// — via `checkpoint::restore_or_rebuild_with_declared`.
519    ///
520    /// `readonly` is the open mode of the database this table belongs to, and it decides one thing
521    /// here: whether a log whose tail was left half-written may be cut back to its last complete
522    /// line (see `LogFile::discard_partial_tail`). A readonly handle only fixes
523    /// its own idea of where the log ends and leaves the file alone.
524    #[allow(clippy::too_many_arguments)]
525    pub fn open_with_snapshot_and_declared_indexes(
526        schema: TableSchema,
527        path: impl AsRef<Path>,
528        snapshot_path: impl AsRef<Path>,
529        schema_hash: &str,
530        snapshot_every: u64,
531        readonly: bool,
532        declared: &[ExpandedIndex],
533    ) -> io::Result<(Self, RestoreOutcome)> {
534        let image_dir = default_image_dir(path.as_ref(), &schema.table);
535        let cypher_fields = cypher_fields_of(&schema);
536        let mut logs = open_logs(&schema, path.as_ref(), readonly)?;
537        let Restored { registry, outcome, gen } = restore_or_rebuild_set_with_declared(
538            snapshot_path.as_ref(),
539            schema.clone(),
540            &mut logs,
541            schema_hash,
542            declared,
543        )?;
544        let seq = registry.max_seq();
545        let queue = WriteQueue::new(schema.table.clone());
546        let table = Table {
547            schema,
548            registry,
549            logs,
550            queue,
551            staged: HashMap::new(),
552            next_op_id: 0,
553            seq,
554            snapshot_path: Some(snapshot_path.as_ref().to_path_buf()),
555            schema_hash: schema_hash.to_string(),
556            snapshot_every,
557            since_snapshot: 0,
558            snapshot_current: outcome == RestoreOutcome::Restored,
559            compaction: CatalogCompaction::Disabled,
560            sync: CatalogSyncMode::Commit,
561            validation: CatalogValidation::Strict,
562            deprecated_fields: BTreeMap::new(),
563            triggers: Vec::new(),
564            image_dir,
565            link_parsing: LinkParsing::Eager,
566            cipher: None,
567            cypher_fields,
568            declared: declared.to_vec(),
569            gen,
570            follow: None,
571            followed_at: None,
572            cache: RecordCache::new(CachePolicy::Disabled),
573        };
574        Ok((table, outcome))
575    }
576
577    /// Attaches the catalog's trigger declarations naming this table as their source — called by
578    /// `Database` after opening/reopening a table. A trigger whose `effect.table` differs from
579    /// this table's own name only fires when the write reaches it through
580    /// [`crate::txn::UnitOfWork::commit`], which holds every table of the database at once; the
581    /// single-table `put`/`patch`/`delete` path below can only write into itself.
582    pub fn set_triggers(&mut self, triggers: Vec<TriggerEntry>) {
583        self.triggers = triggers;
584    }
585
586    pub fn triggers(&self) -> &[TriggerEntry] {
587        &self.triggers
588    }
589
590    /// Overrides the directory `image` field bytes are written under — called by `Database` when
591    /// `options.imageDir` names one, resolved relative to the database directory. Left at its
592    /// `open`/`open_with_snapshot`-computed default (a `<table>.images/` sibling of the log file)
593    /// otherwise.
594    pub fn set_image_dir(&mut self, dir: PathBuf) {
595        self.image_dir = dir;
596    }
597
598    pub fn image_dir(&self) -> &Path {
599        &self.image_dir
600    }
601
602    /// Overrides when a markdown field's links get checked against this table's virtual-path
603    /// index — called by `Database` from `options.linkParsing`, left at `Eager` otherwise.
604    pub fn set_link_parsing(&mut self, mode: LinkParsing) {
605        self.link_parsing = mode;
606    }
607
608    pub fn link_parsing(&self) -> LinkParsing {
609        self.link_parsing
610    }
611
612    /// Overrides what a write naming a field the schema does not declare gets — called by
613    /// `Database` from the table's `validation` entry, left at `Strict` otherwise. `Warn` drops
614    /// such a field and lets the rest of the write through; nothing else about validation moves,
615    /// so a bad type, a missing `required`, a primary key that is not there and a reserved `_` key
616    /// are refused in either mode.
617    pub fn set_validation(&mut self, mode: CatalogValidation) {
618        self.validation = mode;
619    }
620
621    pub fn validation(&self) -> CatalogValidation {
622        self.validation
623    }
624
625    /// `document` as this table takes it: unchanged on a `Strict` table, and without the fields the
626    /// schema does not declare on a `Warn` one. Every write path runs its input through here
627    /// before validating it, which is why nothing undeclared can reach the queue, the log or the
628    /// registry — see [`Table::dropped_field_warnings`] for how a caller learns which fields went.
629    pub(crate) fn accepted_document<'a>(&self, document: &'a Value) -> Cow<'a, Value> {
630        match self.validation {
631            CatalogValidation::Strict => Cow::Borrowed(document),
632            CatalogValidation::Warn => without_undeclared(&self.schema, document),
633        }
634    }
635
636    /// What a write of `document` has to say about fields the schema does not declare — empty on a
637    /// `Strict` table, which refuses such a write instead of warning about it.
638    ///
639    /// A channel of its own, deliberately not folded into [`Table::eager_link_warnings_of`]: that
640    /// one recomputes broken links from the stored record, and a dropped field is not in the
641    /// stored record to be found again, so this has to be asked with the document that was
642    /// written. Answers the same before and after the write, since it reads nothing but the schema.
643    pub fn dropped_field_warnings(&self, document: &Value) -> Vec<String> {
644        if self.validation != CatalogValidation::Warn {
645            return Vec::new();
646        }
647        undeclared_fields(&self.schema, document)
648            .into_iter()
649            .map(|field| format!("field \"{field}\": is not declared in the schema, dropped from the write"))
650            .collect()
651    }
652
653    /// Hands over the fields the catalog marks `deprecated`, each against its note — called by
654    /// `Database` after opening/reopening a table, left empty otherwise. Nothing about a write
655    /// changes: a deprecated field is declared, so it validates, lands and reads back exactly as
656    /// any other; the mark is only ever reported, through [`Table::deprecated_field_warnings`].
657    ///
658    /// A note coming from the catalog is never empty — `catalog::parse_catalog` refuses
659    /// `deprecated: ""` outright — but a caller building a handle by hand can hand one over, and
660    /// [`Table::deprecated_field_warnings`] then names the field on its own.
661    pub fn set_deprecated_fields(&mut self, fields: BTreeMap<String, String>) {
662        self.deprecated_fields = fields;
663    }
664
665    pub fn deprecated_fields(&self) -> &BTreeMap<String, String> {
666        &self.deprecated_fields
667    }
668
669    /// What a write of `document` has to say about fields the catalog marks `deprecated` — one
670    /// message per marked field the document names, quoting the note where the catalog gives one.
671    ///
672    /// Independent of [`Table::validation`]: `deprecated` is a mark on the field, not a mode of the
673    /// table, so a `strict` table warns here exactly as a `warn` one does. A channel of its own,
674    /// deliberately not folded into [`Table::dropped_field_warnings`]: that one is about a field
675    /// the schema does not declare and a `strict` table refuses the write for, this one about a
676    /// field it does declare and every table takes. Answers the same before and after the write,
677    /// since it reads nothing but the document and the mark.
678    pub fn deprecated_field_warnings(&self, document: &Value) -> Vec<String> {
679        if self.deprecated_fields.is_empty() {
680            return Vec::new();
681        }
682        let Some(source) = document.as_object() else {
683            return Vec::new();
684        };
685        source
686            .keys()
687            .filter_map(|field| self.deprecated_fields.get_key_value(field.as_str()))
688            .map(|(field, note)| {
689                if note.is_empty() {
690                    format!("field \"{field}\": is deprecated")
691                } else {
692                    format!("field \"{field}\": is deprecated — {note}")
693                }
694            })
695            .collect()
696    }
697
698    /// Arms automatic compaction with the thresholds `Database::open` resolved for this table —
699    /// see [`Table::compact_if_due`] for what they are measured against and when.
700    pub fn set_compaction(&mut self, compaction: CatalogCompaction) {
701        self.compaction = compaction;
702    }
703
704    pub fn compaction(&self) -> CatalogCompaction {
705        self.compaction
706    }
707
708    /// Arms this handle's read cache — the bounded LRU of parsed documents [`Table::get`] answers a
709    /// repeated read of the same key from. Replaces whatever was cached so far, so re-arming a live
710    /// table starts it from cold rather than from documents the old policy admitted.
711    pub fn set_cache(&mut self, cache: CachePolicy) {
712        self.cache = RecordCache::new(cache);
713    }
714
715    pub fn cache_policy(&self) -> CachePolicy {
716        self.cache.policy()
717    }
718
719    /// Sets how durable this table's appends are — called by `Database` from `options.sync`, left
720    /// at `Commit` otherwise. `Commit` `fsync`s the log on every flush; `Never` skips the `fsync`
721    /// and leaves the bytes to the kernel, so a crash can lose appends this table already
722    /// acknowledged. Nothing else changes: the writes, the registry and the snapshots are the same.
723    pub fn set_sync(&mut self, sync: CatalogSyncMode) {
724        self.sync = sync;
725        self.logs.set_fsync(sync == CatalogSyncMode::Commit);
726    }
727
728    pub fn sync_mode(&self) -> CatalogSyncMode {
729        self.sync
730    }
731
732    /// Sets the keyring `put`/`patch` seal `cypher` fields with and `get`/`query` open them with —
733    /// called by `Database` from `options.cypher`, left at `None` otherwise, which is fine for a
734    /// table whose schema declares no `cypher` field and an error the moment one tries to write or
735    /// read one that does.
736    pub fn set_cipher(&mut self, cipher: Option<FieldCipher>) {
737        self.cipher = cipher;
738        // NOTE: cached documents were opened with the old keyring — what they read as may change.
739        self.cache.clear();
740    }
741
742    /// Writes the bytes of every `image` field `source` carries a `content` string for, content-
743    /// addressed under [`Table::image_dir`] — skipped when a file already sits at that path, since
744    /// the same hash means the same bytes (free dedup) and skipped for a field `source` does not
745    /// mention (an untouched image field on a patch has nothing new to write). Reads `content` off
746    /// `source` directly rather than the coerced record, which no longer carries it past validation.
747    fn write_image_fields(&self, source: &Value) -> io::Result<()> {
748        let Some(object) = source.as_object() else {
749            return Ok(());
750        };
751        for (name, spec) in &self.schema.fields {
752            if spec.field_type != FieldType::Image {
753                continue;
754            }
755            let Some(content) = object
756                .get(name)
757                .and_then(|value| value.get("content"))
758                .and_then(Value::as_str)
759            else {
760                continue;
761            };
762            let decoded = crate::schema::decode_image_content(content)
763                .map_err(|reason| io::Error::new(io::ErrorKind::InvalidData, reason))?;
764            self.write_image_bytes(&decoded)?;
765        }
766        Ok(())
767    }
768
769    /// Every markdown field of `record` scanned against the table's whole virtual-path index —
770    /// a link resolves if any markdown/image field (this table's `virtual_path_fields()`) holds
771    /// the target path, not only the field the link was found in.
772    fn broken_markdown_links(&self, record: &HaysonRecord) -> Vec<BrokenLink> {
773        let mut broken = Vec::new();
774        for (name, spec) in &self.schema.fields {
775            if spec.field_type != FieldType::Markdown {
776                continue;
777            }
778            let Some(content) = record
779                .get(name)
780                .and_then(|value| value.get("content"))
781                .and_then(Value::as_str)
782            else {
783                continue;
784            };
785            let field_broken = broken_links(content, |target| {
786                self.registry
787                    .virtual_path_fields()
788                    .any(|field| self.registry.virtual_path_holder(field, target).is_some())
789            });
790            broken.extend(field_broken.into_iter().map(|target| BrokenLink {
791                field: name.clone(),
792                path: target,
793            }));
794        }
795        broken
796    }
797
798    /// Diagnostics for a just-written `record` — empty unless [`Table::link_parsing`] is `Eager`,
799    /// since a `Lazy` table only checks links on demand via [`Table::broken_links_of`].
800    fn eager_link_warnings(&self, record: &HaysonRecord) -> Vec<BrokenLink> {
801        if self.link_parsing != LinkParsing::Eager {
802            return Vec::new();
803        }
804        self.broken_markdown_links(record)
805    }
806
807    /// The diagnostics [`PutOutcome::warnings`] would carry for the record now stored under `key` —
808    /// empty unless [`Table::link_parsing`] is `Eager`, exactly as [`Table::put_with_warnings`].
809    ///
810    /// It exists for a binding that cannot use `put_with_warnings` because it stages and flushes in
811    /// two separate calls across an FFI boundary and so never holds a [`PutOutcome`]; asking here
812    /// once the write has landed gives the same answer, and keeps the `Eager` gate in one place
813    /// rather than copied into every binding.
814    pub fn eager_link_warnings_of(&self, key: &str) -> Result<Vec<BrokenLink>, TableError> {
815        if self.link_parsing != LinkParsing::Eager {
816            return Ok(Vec::new());
817        }
818        self.broken_links_of(key)
819    }
820
821    /// On-demand link check for a `Lazy` table (or any table) — reads `key`'s current record and
822    /// reports every markdown link that does not resolve, regardless of [`Table::link_parsing`].
823    pub fn broken_links_of(&self, key: &str) -> Result<Vec<BrokenLink>, TableError> {
824        let record = self.get(key)?.ok_or_else(|| {
825            TableError::NotFound(HaysonNotFoundError {
826                table: self.schema.table.clone(),
827                key: key.to_string(),
828            })
829        })?;
830        Ok(self.broken_markdown_links(&record))
831    }
832
833    fn write_image_bytes(&self, bytes: &[u8]) -> io::Result<()> {
834        let file_path = self.image_dir.join(physical_path_of(bytes));
835        if file_path.exists() {
836            return Ok(());
837        }
838        fs::create_dir_all(&self.image_dir)?;
839        fs::write(file_path, bytes)
840    }
841
842    /// Writes `<table>.idx` for the registry's current state and resets the since-last-snapshot
843    /// counter — mirrors the TS `Table.snapshot()`. A no-op on a table opened via [`Table::open`],
844    /// which carries no `snapshot_path`.
845    pub fn snapshot(&mut self) -> io::Result<()> {
846        self.flush()?;
847        self.write_snapshot_now()
848    }
849
850    /// [`Table::snapshot`] without the leading flush — for the callers already inside the write
851    /// path, where the queue is drained but the staging window is still open for triggers.
852    fn write_snapshot_now(&mut self) -> io::Result<()> {
853        let Some(path) = self.snapshot_path.clone() else {
854            return Ok(());
855        };
856        write_snapshot_set(
857            &path,
858            &self.registry,
859            &self.schema.table,
860            &self.schema_hash,
861            self.gen,
862            &self.logs,
863        )?;
864        self.since_snapshot = 0;
865        self.snapshot_current = true;
866        Ok(())
867    }
868
869    /// Whether this table has writes since its last snapshot that `close()` still needs to flush —
870    /// always `false` on a table opened via [`Table::open`].
871    pub fn needs_snapshot(&self) -> bool {
872        self.snapshot_path.is_some() && !self.snapshot_current
873    }
874
875    /// Moves every record of this table onto the cipher's current write key, eagerly — the
876    /// counterpart of the lazy rotation a record gets for free on its next organic write.
877    ///
878    /// A record is read, decrypted with whichever key sealed it and written back under the same
879    /// primary key, so the plaintext is preserved and nothing new is created. Records already on
880    /// the current key are not rewritten at all, which is what makes running this repeatedly cost
881    /// one read per record and nothing else. A table declaring no `cypher` field answers zero
882    /// without reading anything.
883    ///
884    /// A field naming a key the keyring does not hold follows the table's own
885    /// [`MissingKeyPolicy`], for the reason a maintenance pass has to: under the default `Throw`
886    /// the run is refused at that record, because silently leaving a record behind is exactly what
887    /// an operator running a rotation is trying to rule out; under `Omit`/`Raw` it is counted in
888    /// [`RotationResult::skipped`] and left byte for byte, since neither of those policies yields
889    /// a plaintext that could be re-sealed — `Omit` drops the field and `Raw` hands back the
890    /// envelope, and writing either one back would destroy the value.
891    ///
892    /// Records rotated before a refusal stay rotated: every write lands as a complete record, so a
893    /// refused run is a partial one, not a broken one, and re-running it after adding the missing
894    /// key finishes the job. No trigger fires — a rotation changes no value a trigger could be
895    /// about.
896    pub fn rotate_cypher_keys(&mut self) -> Result<RotationResult, TableError> {
897        let mut result = RotationResult::default();
898        if self.cypher_fields.is_empty() {
899            return Ok(result);
900        }
901        if self.logs.is_readonly() {
902            return Err(TableError::Io(io::Error::other(format!(
903                "table \"{}\" is readonly — rotating cypher keys is not available",
904                self.schema.table
905            ))));
906        }
907        // NOTE: a staged write is not in the log yet, so it would be rotated as the record it replaces.
908        self.flush()?;
909        let Some(cipher) = self.cipher.clone() else {
910            return Err(TableError::Cipher(Box::new(HaysonCipherError::new(
911                self.schema.table.clone(),
912                "",
913                "cannot rotate cypher keys without a key",
914            ))));
915        };
916
917        let keys: Vec<String> = self.keys().cloned().collect();
918        for key in keys {
919            let Some(sealed) = self.sealed_record(&key)? else {
920                continue;
921            };
922            match self.rotation_verdict(&sealed, &cipher) {
923                RotationVerdict::Current => result.already_current += 1,
924                RotationVerdict::Missing(key_id) => match cipher.on_missing_key() {
925                    MissingKeyPolicy::Throw => {
926                        return Err(TableError::Cipher(Box::new(
927                            HaysonCipherError::new(
928                                self.schema.table.clone(),
929                                "",
930                                format!("record \"{key}\" was sealed with key \"{key_id}\", which was not given"),
931                            )
932                            .with_key_id(&key_id),
933                        )))
934                    }
935                    MissingKeyPolicy::Omit | MissingKeyPolicy::Raw => result.skipped += 1,
936                },
937                RotationVerdict::Rotate => {
938                    let opened = open_record(&self.schema, &key, &sealed, Some(&cipher), &self.cypher_fields)
939                        .map_err(|error| TableError::Cipher(Box::new(error)))?;
940                    let document = Value::Object(opened.into_iter().collect());
941                    self.put_from_buffer(&document, false)?;
942                    result.rotated += 1;
943                }
944            }
945        }
946        Ok(result)
947    }
948
949    /// `key` exactly as the log holds it — `cypher` fields still sealed, because the decision
950    /// [`Table::rotate_cypher_keys`] makes is about the envelope, not about the value inside it.
951    fn sealed_record(&self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
952        let mut source = LogSetRecordSource::new(&self.registry, &self.logs);
953        source.load(key).map_err(TableError::from)
954    }
955
956    fn rotation_verdict(&self, sealed: &HaysonRecord, cipher: &FieldCipher) -> RotationVerdict {
957        let mut rotate = false;
958        for field in &self.cypher_fields {
959            let Some(value) = sealed.get(field) else { continue };
960            if value.is_null() {
961                continue;
962            }
963            match envelope_key_id(value) {
964                Some(key_id) if !cipher.knows(key_id) => return RotationVerdict::Missing(key_id.to_string()),
965                Some(key_id) => rotate |= key_id != cipher.write_key_id(),
966                // A value in the clear: the field was marked `cypher` after this record landed, so
967                // rotating it is what seals it for the first time.
968                None => rotate = true,
969            }
970        }
971        if rotate {
972            RotationVerdict::Rotate
973        } else {
974            RotationVerdict::Current
975        }
976    }
977
978    /// Rewrites the log to keep only the live version of each key, then remaps the registry's
979    /// slots onto the new offsets — see `compaction::compact_log`. A partitioned table
980    /// compacts one partition file at a time and remaps each on its own, so a partition nothing was
981    /// written to is left byte for byte as it was; the returned result is their sum.
982    ///
983    /// The snapshot on disk describes the log as it was before the rewrite, so it is deleted before
984    /// the first swap and written afresh once every partition has landed — the port of the TS
985    /// `beforeSwap: removeSnapshot(...)` plus the trailing `snapshot()`. Deleting first is what
986    /// keeps a crash mid-compaction honest: whichever log survives, no index claims to pair with it.
987    pub fn compact(&mut self) -> io::Result<crate::compaction::CompactionResult> {
988        // NOTE: a staged write would be rewritten out of existence — it is not in the log yet.
989        self.flush()?;
990        self.cache.clear();
991        let mut total = crate::compaction::CompactionResult {
992            slots: HashMap::new(),
993            records: 0,
994            bytes_before: 0,
995            bytes_after: 0,
996            reclaimed_bytes: 0,
997        };
998        let mut swapped = false;
999        for part in self.logs.parts() {
1000            if !swapped {
1001                if let Some(path) = self.snapshot_path.as_deref() {
1002                    remove_snapshot(path)?;
1003                }
1004                self.snapshot_current = false;
1005                swapped = true;
1006            }
1007            let Some(log) = self.logs.log_of_mut(&part) else {
1008                continue;
1009            };
1010            let result = crate::compaction::compact_log(log)?;
1011            self.registry.remap(&result.slots, &part);
1012            total.records += result.records;
1013            total.bytes_before += result.bytes_before;
1014            total.bytes_after += result.bytes_after;
1015            total.reclaimed_bytes += result.reclaimed_bytes;
1016            total.slots.extend(result.slots);
1017        }
1018        if swapped {
1019            // NOTE: bump before the snapshot -- the file has to carry the generation it describes.
1020            self.gen += 1;
1021            self.snapshot()?;
1022        }
1023        Ok(total)
1024    }
1025
1026    /// Catches up with whatever another process did to this table's files since this handle last
1027    /// looked — the port of the TS `Table.refresh()`. Three things can have happened to a
1028    /// partition, and each is answered differently:
1029    ///
1030    /// * **nothing** — the file is the same size, on the same inode. One `stat` and no read at all,
1031    ///   which is what makes calling this on a read path affordable;
1032    /// * **an append** — the file grew on the same inode, so everything already applied is still at
1033    ///   the offset it was applied at and only the tail past this handle's watermark is scanned;
1034    /// * **a rewrite** — a different inode, or bytes gone from a file this engine only ever appends
1035    ///   to. That is what a foreign [`Table::compact`] leaves behind (it lands through
1036    ///   `atomic::write_atomic`, so the path gets a brand new file), and it invalidates every
1037    ///   offset this registry holds. There is no sound way to scan on from a watermark into a file
1038    ///   that was rewritten under it, so the whole registry is rebuilt from the files as they are.
1039    ///
1040    /// A partition file another process created is opened here too (`LogSet::discover`) and read in
1041    /// full, since none of it has ever been seen.
1042    pub fn refresh(&mut self) -> io::Result<RefreshOutcome> {
1043        let appeared = self.logs.discover()?;
1044        let mut entries = 0;
1045
1046        for part in self.logs.parts() {
1047            if appeared.contains(&part) {
1048                // NOTE: a partition opened just now is unread in full, not merely behind by a tail.
1049                let Some(log) = self.logs.log_of(&part) else { continue };
1050                let scan = log.scan(0)?;
1051                entries += scan.entries.len();
1052                self.invalidate_scanned(&scan);
1053                self.registry.apply_scan_in(scan, &part);
1054                continue;
1055            }
1056
1057            let Some(log) = self.logs.log_of_mut(&part) else {
1058                continue;
1059            };
1060            let Some(probe) = log.probe() else { continue };
1061            if log.replaced_by(&probe) {
1062                self.reload()?;
1063                return Ok(RefreshOutcome::Reloaded);
1064            }
1065            if probe.size == log.size() {
1066                continue;
1067            }
1068            let from = log.size();
1069            log.adopt_size(probe.size);
1070            let scan = log.scan(from)?;
1071            // NOTE: rewind to the last complete line — a foreign append may be only half on disk.
1072            log.adopt_size(scan.end_offset);
1073            entries += scan.entries.len();
1074            self.invalidate_scanned(&scan);
1075            self.registry.apply_scan_in(scan, &part);
1076        }
1077
1078        self.seq = self.seq.max(self.registry.max_seq());
1079        if entries == 0 {
1080            return Ok(RefreshOutcome::Unchanged);
1081        }
1082        self.snapshot_current = false;
1083        Ok(RefreshOutcome::CaughtUp { entries })
1084    }
1085
1086    /// Drops the cached document of every key a foreign scan carries, before its entries reach the
1087    /// registry: those keys now point at another writer's line, and the cache holds what this
1088    /// handle read before that line existed.
1089    fn invalidate_scanned(&self, scan: &ScanResult) {
1090        for found in &scan.entries {
1091            self.cache.invalidate(&found.entry.meta.key);
1092        }
1093    }
1094
1095    /// Sets how often this handle catches up with foreign writes on its own — `None` to follow
1096    /// nothing, which is where every constructor starts. Called by `Database::open`, which resolves
1097    /// the policy from `OpenOptions::follow` and `OpenOptions::readonly`.
1098    pub fn set_follow(&mut self, follow: Option<FollowPolicy>) {
1099        self.follow = follow;
1100    }
1101
1102    pub fn follow(&self) -> Option<FollowPolicy> {
1103        self.follow
1104    }
1105
1106    /// Catches this handle up when a follow policy is set and its interval has elapsed — the port
1107    /// of the TS `Table.#followIfDue`, and what the `*_following` read methods below run first.
1108    ///
1109    /// The interval gates the check, not the catching up: once the gate passes, [`Table::refresh`]
1110    /// decides whether anything actually happened, and it costs one `stat` per log file to find out
1111    /// that nothing did. The clock is moved on whenever the gate passes, so a table nobody else is
1112    /// writing to is probed at most once per interval no matter how many reads run in between.
1113    ///
1114    /// @returns what the catch-up found, or `None` when no policy is set or the interval has not
1115    ///   elapsed yet.
1116    pub fn follow_if_due(&mut self) -> io::Result<Option<RefreshOutcome>> {
1117        if !self.follow_is_due() {
1118            return Ok(None);
1119        }
1120        self.followed_at = Some(Instant::now());
1121        Ok(Some(self.refresh()?))
1122    }
1123
1124    /// Whether [`Table::follow_if_due`] would reach for the files if it ran now: a policy is set and
1125    /// its interval has elapsed. Only the gate — a clock comparison against `followed_at`, no `stat`
1126    /// and no read — so this is the cheap half of the follow-aware read path.
1127    ///
1128    /// It is public because a binding that has to take an exclusive lock to catch up asks this
1129    /// first, under a shared one, and skips the exclusive lock entirely when the answer is `false`.
1130    /// A writable handle resolves to no policy at all, so for it the answer is always `false`.
1131    pub fn follow_is_due(&self) -> bool {
1132        let Some(policy) = self.follow else { return false };
1133        policy.interval.is_zero() || self.followed_at.is_none_or(|at| at.elapsed() >= policy.interval)
1134    }
1135
1136    /// Whether anything staged is still waiting for [`Table::flush`] — the exact condition that
1137    /// makes that call more than a no-op, and what a read which cannot see the staging window has to
1138    /// drain before it can answer. [`Table::query`] and the aggregates plan over the registry rather
1139    /// than over the staged writes, which is why [`Table::query_following`] flushes and
1140    /// [`Table::get_following`] does not.
1141    pub fn has_staged_writes(&self) -> bool {
1142        self.queue.depth() > 0 || !self.staged.is_empty()
1143    }
1144
1145    /// [`Table::get`] behind [`Table::follow_if_due`] — the read path the TS `Table.get` is, where
1146    /// the freshness check is part of the read rather than something a caller remembers to do.
1147    ///
1148    /// It is a second method rather than the behaviour of `get` itself because catching up needs
1149    /// `&mut self` and `get` deliberately does not: every read that runs inside an already-borrowed
1150    /// database (integrity checks, unit-of-work reads, `drop_field`'s residue scan) goes through the
1151    /// shared-borrow one, and none of those wants a foreign write landing underneath it mid-pass.
1152    pub fn get_following(&mut self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
1153        self.follow_if_due()?;
1154        self.get(key)
1155    }
1156
1157    /// [`Table::get_following`] for several keys, checking freshness once for the whole batch —
1158    /// the TS `Table.getMany`, which calls `#followIfDue` once and then reads key by key.
1159    pub fn get_many_following(&mut self, keys: &[String]) -> Result<Vec<Option<HaysonRecord>>, TableError> {
1160        self.follow_if_due()?;
1161        keys.iter().map(|key| self.get(key)).collect()
1162    }
1163
1164    /// [`Table::query`] behind [`Table::follow_if_due`] — see [`Table::get_following`] for why the
1165    /// follow-aware read is a separate method.
1166    pub fn query_following(&mut self, spec: &QuerySpec) -> Result<QueryResult, TableError> {
1167        self.flush()?;
1168        self.follow_if_due()?;
1169        self.query(spec)
1170    }
1171
1172    /// [`Table::aggregate`] behind [`Table::follow_if_due`].
1173    pub fn aggregate_following(
1174        &mut self,
1175        spec: &QuerySpec,
1176        aggregate: &AggregateSpec,
1177    ) -> Result<AggregateResult, TableError> {
1178        self.flush()?;
1179        self.follow_if_due()?;
1180        self.aggregate(spec, aggregate)
1181    }
1182
1183    /// [`Table::aggregate_grouped`] behind [`Table::follow_if_due`].
1184    pub fn aggregate_grouped_following(
1185        &mut self,
1186        spec: &QuerySpec,
1187        aggregate: &AggregateSpec,
1188    ) -> Result<GroupedAggregateResult, TableError> {
1189        self.flush()?;
1190        self.follow_if_due()?;
1191        self.aggregate_grouped(spec, aggregate)
1192    }
1193
1194    /// Throws this handle's registry away and builds it again from every log file, each reopened
1195    /// first so the descriptor points at the file now at that path rather than the one that was
1196    /// there when this handle opened. Used only by [`Table::refresh`], for the rewrite case.
1197    fn reload(&mut self) -> io::Result<()> {
1198        self.logs.discover()?;
1199        let mut registry = TableRegistry::with_declared_indexes(self.schema.clone(), &self.declared);
1200        for part in self.logs.parts() {
1201            let Some(log) = self.logs.log_of_mut(&part) else {
1202                continue;
1203            };
1204            log.reopen()?;
1205            let scan = log.scan(0)?;
1206            // NOTE: a half-written tail belongs to the writer that left it — only its end is taken.
1207            log.adopt_size(scan.end_offset);
1208            registry.apply_scan_in(scan, &part);
1209        }
1210        self.registry = registry;
1211        self.cache.clear();
1212        self.seq = self.seq.max(self.registry.max_seq());
1213        self.gen += 1;
1214        self.snapshot_current = false;
1215        Ok(())
1216    }
1217
1218    /// Every key the table currently holds, in no particular order.
1219    pub fn keys(&self) -> impl Iterator<Item = &String> {
1220        self.registry.keys()
1221    }
1222
1223    /// Whether the table holds a record under `key`, counting a write staged but not yet flushed —
1224    /// the TS `Table.has`, which answers from the write queue before it looks at the log.
1225    pub fn has(&self, key: &str) -> bool {
1226        match self.staged.get(key) {
1227            Some(staged) => staged.record.is_some(),
1228            None => self.registry.slot_of(key).is_some(),
1229        }
1230    }
1231
1232    /// Whether any live, flushed record holds `value` on `field`. `field` has to be this table's
1233    /// primary key or a `unique` field — every caller reaches this through a relation's `to.field`,
1234    /// which the catalog's relation parser already restricts to one of those two (ADR-axje/0043) —
1235    /// anything else always answers `false`. O(1) via the registry: the primary-key case is
1236    /// [`Table::has`], the unique-field case is the same lookup `assert_unique` runs for the
1237    /// opposite question.
1238    ///
1239    /// NOTE: registry-only, unlike `has()` — a record staged but not yet flushed on a unique field
1240    /// is invisible here, matching the eventual-consistency window the write queue already has.
1241    pub fn contains_value(&self, field: &str, value: &Value) -> bool {
1242        if field == self.schema.primary_key {
1243            return match value {
1244                Value::String(s) => self.has(s),
1245                Value::Number(n) => self.has(&n.to_string()),
1246                _ => false,
1247            };
1248        }
1249        self.registry.unique_holder(field, value).is_some()
1250    }
1251
1252    /// Every live record's key where `field` equals `value` — the reverse of [`Table::contains_value`],
1253    /// used to find the rows an `on_delete` relation's `from.field` points at. Unlike
1254    /// `contains_value`, `field` is not restricted to the primary key or a `unique` field: an
1255    /// `exact`/`sorted` index on it (schema `index:` sugar or a declared one) answers in
1256    /// O(matches) via the registry; without one this falls back to a full scan, exactly like
1257    /// `check_integrity`'s own answer to the same question (`catalog::integrity::records_of`) —
1258    /// there is no reverse index guaranteed to exist on a relation's `from.field` today.
1259    pub fn keys_referencing(&self, field: &str, value: &Value) -> Result<Vec<String>, TableError> {
1260        if let Some(keys) = self.registry.lookup_exact(field, value) {
1261            return Ok(keys.iter().cloned().collect());
1262        }
1263        let all = self.query(&QueryBuilder::new().spec())?;
1264        let mut matches = Vec::new();
1265        for key in &all.keys {
1266            if let Some(record) = self.get(key)? {
1267                if record.get(field) == Some(value) {
1268                    matches.push(key.clone());
1269                }
1270            }
1271        }
1272        Ok(matches)
1273    }
1274
1275    /// How many live records the table holds — the TS `Table.size`. Tombstoned keys and superseded
1276    /// versions are not among them; they are bytes in the log, not records in the registry.
1277    ///
1278    /// Staged writes are deliberately not counted, exactly as in the TS reference: this is the
1279    /// registry's own number, and a key first written in the current staging window joins it when
1280    /// the write lands. [`Table::keys`] leaves them out for the same reason.
1281    pub fn len(&self) -> usize {
1282        self.registry.records()
1283    }
1284
1285    pub fn is_empty(&self) -> bool {
1286        self.len() == 0
1287    }
1288
1289    /// The fields claimed by writes staged against `key` but not yet appended — the TS
1290    /// `Table.pending(key)`. Empty unless a caller staged through [`Table::stage_put`] and friends
1291    /// without flushing yet: `put`/`patch`/`delete` stage and flush inside the one call.
1292    pub fn pending(&self, key: &str) -> HashSet<String> {
1293        self.queue.pending(key)
1294    }
1295
1296    /// The table's counters, in the shape of the TS `TableStats` — see [`TableStats`] for the
1297    /// member whose meaning is narrower here than there.
1298    pub fn stats(&self) -> TableStats {
1299        let stats = self.registry.stats();
1300        TableStats {
1301            records: stats.records,
1302            live_bytes: stats.live_bytes,
1303            dead_bytes: stats.dead_bytes,
1304            log_bytes: self.logs.total_size(),
1305            queue_depth: self.queue.depth(),
1306            gen: self.gen,
1307            seq: self.seq,
1308        }
1309    }
1310
1311    fn note_writes(&mut self, count: usize) -> io::Result<()> {
1312        if count == 0 {
1313            return Ok(());
1314        }
1315        self.since_snapshot += count as u64;
1316        self.snapshot_current = false;
1317        self.compact_if_due()?;
1318        if self.snapshot_every > 0 && self.since_snapshot >= self.snapshot_every {
1319            self.write_snapshot_now()?;
1320        }
1321        Ok(())
1322    }
1323
1324    /// Rewrites every partition whose garbage has grown past the policy [`Table::set_compaction`]
1325    /// armed — the port of the TS `Table.#maybeCompact`, run after each batch of writes lands.
1326    ///
1327    /// Both thresholds have to be met, and both are read straight off the registry's per-partition
1328    /// byte tallies (`TableRegistry::stats_of`), which every put and delete already maintains: this
1329    /// runs on every write, so it may not cost a single `stat` of its own. `min_dead_bytes` is the
1330    /// absolute floor that keeps a small log from being rewritten over and over, `dead_bytes_ratio`
1331    /// the share of the partition that has to be garbage before rewriting it is worth the I/O.
1332    ///
1333    /// A partitioned table is measured one partition at a time, so a partition nobody overwrote is
1334    /// left alone; the generation counter still moves once for the whole pass, not once per file,
1335    /// because that is what readers compare to notice the offsets moved under them.
1336    fn compact_if_due(&mut self) -> io::Result<()> {
1337        let CatalogCompaction::Enabled {
1338            dead_bytes_ratio,
1339            min_dead_bytes,
1340        } = self.compaction
1341        else {
1342            return Ok(());
1343        };
1344        let mut compacted = false;
1345        for part in self.logs.parts() {
1346            let bytes = self.registry.stats_of(&part);
1347            let total = bytes.live_bytes + bytes.dead_bytes;
1348            if total == 0 || bytes.dead_bytes < min_dead_bytes {
1349                continue;
1350            }
1351            if (bytes.dead_bytes as f64) / (total as f64) < dead_bytes_ratio {
1352                continue;
1353            }
1354            let Some(log) = self.logs.log_of_mut(&part) else {
1355                continue;
1356            };
1357            if !compacted {
1358                // NOTE: the snapshot on disk pairs with the log about to be replaced -- drop it first.
1359                if let Some(path) = self.snapshot_path.as_deref() {
1360                    remove_snapshot(path)?;
1361                }
1362                self.snapshot_current = false;
1363            }
1364            let result = crate::compaction::compact_log(log)?;
1365            self.registry.remap(&result.slots, &part);
1366            compacted = true;
1367        }
1368        if !compacted {
1369            return Ok(());
1370        }
1371        self.cache.clear();
1372        // NOTE: bump before the snapshot -- the file has to carry the generation it describes.
1373        self.gen += 1;
1374        self.write_snapshot_now()
1375    }
1376
1377    pub fn name(&self) -> &str {
1378        &self.schema.table
1379    }
1380
1381    pub fn schema(&self) -> &TableSchema {
1382        &self.schema
1383    }
1384
1385    /// The expanded index set this handle was opened with and has served ever since — the answer
1386    /// to "what does this table actually index", as opposed to what the catalog on disk now says.
1387    /// A reload rebuilds its registry from exactly this list, so it stays true across `refresh`.
1388    pub fn declared_indexes(&self) -> &[ExpandedIndex] {
1389        &self.declared
1390    }
1391
1392    /// Reads `key`, answering from the staging window first — the TS "read your own pending write".
1393    /// A staged record is handed back as it was validated, in plaintext, so a `cypher` field reads
1394    /// the same before and after the write that carries it lands.
1395    pub fn get(&self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
1396        match self.staged.get(key) {
1397            Some(staged) => Ok(staged.record.clone()),
1398            None => self.committed(key),
1399        }
1400    }
1401
1402    /// `key` as the log holds it, ignoring anything staged — what a staging window needs to record
1403    /// as the `old` a trigger will be fired with, and the only read that must not see itself.
1404    fn committed(&self, key: &str) -> Result<Option<HaysonRecord>, TableError> {
1405        if let Some(cached) = self.cache.get(key) {
1406            return Ok(Some(cached));
1407        }
1408        let found = self.with_record_source(|source| source.load(key))?;
1409        if let Some(record) = &found {
1410            self.cache.insert(key, record);
1411        }
1412        Ok(found)
1413    }
1414
1415    /// Builds the [`LogRecordSource`] every read path (`get`, `query`, `aggregate`,
1416    /// `aggregate_grouped`) shares, wired to decrypt `cypher` fields and to project the record onto
1417    /// the live schema the moment it is parsed off the log — the single chokepoint that keeps
1418    /// `get`/`query`/`patch`'s read-merge all hydrating through the same hook, mirroring the TS
1419    /// `Table.#read`/`#open`.
1420    ///
1421    /// The hook now runs for every table, not only one with a `cypher` field: the log is append-only,
1422    /// so a line written before a `dropField` still carries the dropped field, and the schema is the
1423    /// only thing that says what a record has. See `packages/hayson/test/drop-field.test.ts`.
1424    fn with_record_source<T>(
1425        &self,
1426        f: impl FnOnce(&mut dyn RecordSource) -> Result<T, RecordSourceError>,
1427    ) -> Result<T, TableError> {
1428        let open = |key: &str, record: HaysonRecord| -> Result<HaysonRecord, RecordSourceError> {
1429            let opened = if self.cypher_fields.is_empty() {
1430                record
1431            } else {
1432                open_record(&self.schema, key, &record, self.cipher.as_ref(), &self.cypher_fields)?
1433            };
1434            Ok(project_onto_schema(&self.schema, opened))
1435        };
1436        let mut source = LogSetRecordSource::with_open(&self.registry, &self.logs, &open);
1437        f(&mut source).map_err(TableError::from)
1438    }
1439
1440    /// Every log line naming `key`, oldest first — what [`Table::get`] answers with is the last of
1441    /// them.
1442    ///
1443    /// Reads the log files rather than the registry, because the registry holds exactly one slot per
1444    /// key (the live one) and nothing that points at a version already overwritten: the cost is a
1445    /// full scan of this table's logs, every partition of a partitioned table. Nothing here is
1446    /// hydrated the way a read is — see [`HistoryEntry::document`].
1447    ///
1448    /// Writes staged but not flushed are not on disk yet, so they are not in the answer either;
1449    /// [`Table::flush`] first if they should be.
1450    pub fn history(&self, key: &str) -> io::Result<TableHistory> {
1451        let mut entries = Vec::new();
1452        let mut scanned = Vec::new();
1453        for part in self.logs.parts() {
1454            let Some(log) = self.logs.log_of(&part) else { continue };
1455            scanned.push(log.path().to_path_buf());
1456            for found in log.scan(0)?.entries {
1457                if found.entry.meta.key != key {
1458                    continue;
1459                }
1460                entries.push(HistoryEntry {
1461                    seq: found.entry.meta.seq,
1462                    ts: found.entry.meta.ts,
1463                    op: found.entry.meta.op,
1464                    partition: part.clone(),
1465                    offset: found.slot.offset,
1466                    length: found.slot.length,
1467                    document: found.entry.data,
1468                });
1469            }
1470        }
1471        entries.sort_by_key(|entry| entry.seq);
1472        Ok(TableHistory { entries, scanned })
1473    }
1474
1475    pub fn put(&mut self, document: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1476        self.put_impl(document, force, true).map(PutOutcome::into_record)
1477    }
1478
1479    /// [`Table::put`] for a record whose `cypher` fields named in `sealed` already hold an envelope
1480    /// this system produced — a backup being restored, or a copy moving a value between databases
1481    /// without ever holding it in the clear. Those fields reach the log exactly as given; every
1482    /// other field, `cypher` or not, takes the ordinary write path and is sealed.
1483    ///
1484    /// This is the **only** way to store a `cypher` field without sealing it, and it is deliberately
1485    /// not reachable from a document: an ordinary write seals a value that merely looks like an
1486    /// envelope just like any other, which is the whole point of the entry point existing. Naming a
1487    /// field here is a claim, checked as far as the keyring allows — see
1488    /// [`crate::crypto::assert_carried_seals`] for what is refused and for the one case that is
1489    /// taken on trust. Do not wire it to untrusted input: nothing below it can tell a caller's
1490    /// string from a genuine envelope once the key that would authenticate it is absent.
1491    ///
1492    /// The record returned is the one that was stored, so a carried field reads back as its
1493    /// envelope rather than as plaintext — unlike [`Table::put`], which is handed plaintext to
1494    /// begin with. A later [`Table::get`] opens it like any other stored value.
1495    pub fn put_sealed(&mut self, document: &Value, sealed: &[String], force: bool) -> Result<HaysonRecord, TableError> {
1496        if sealed.is_empty() {
1497            return self.put(document, force);
1498        }
1499        let accepted = self.accepted_document(document);
1500        let document = accepted.as_ref();
1501        let candidate = validate_record(&self.schema, document, ValidateOptions::default())?;
1502        let key = primary_key_of(&self.schema, &candidate)?;
1503        for field in sealed {
1504            if !self.cypher_fields.iter().any(|name| name == field) {
1505                return Err(TableError::Cipher(Box::new(HaysonCipherError::new(
1506                    self.schema.table.clone(),
1507                    field.clone(),
1508                    "cannot be carried over as already sealed — it is not declared \"cypher\"",
1509                ))));
1510            }
1511        }
1512        // NOTE: refuse before staging — a refusal from inside the commit would leave a claim held.
1513        assert_carried_seals(&self.schema, &key, &candidate, self.cipher.as_ref(), sealed)?;
1514
1515        self.assert_partition_stable_against_staged(document)?;
1516        let key = self
1517            .queue
1518            .stage_put_carrying(&self.schema, &self.registry, document, sealed.to_vec(), force)?;
1519        let Some(record) = self.queue.last_put_record().cloned() else {
1520            unreachable!("stage_put_carrying queues exactly one put entry")
1521        };
1522        self.write_image_fields(document)?;
1523        self.stage(key, Some(record.clone()), true)?;
1524        self.flush()?;
1525        Ok(record)
1526    }
1527
1528    /// Same as [`Table::put`], but the outcome also carries diagnostics — currently only broken
1529    /// markdown links found while [`Table::link_parsing`] is `Eager`. See [`PutOutcome`] for the
1530    /// shape and why `put` stays untouched alongside it.
1531    pub fn put_with_warnings(&mut self, document: &Value, force: bool) -> Result<PutOutcome, TableError> {
1532        self.put_impl(document, force, true)
1533    }
1534
1535    /// Same as [`Table::put`] but never fires triggers — used only by
1536    /// `buffer::apply_clean_entries`, which replays an entry whose originating live write already
1537    /// fired them once; firing again here would double the effect.
1538    pub(crate) fn put_from_buffer(&mut self, document: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1539        self.put_impl(document, force, false).map(PutOutcome::into_record)
1540    }
1541
1542    fn put_impl(&mut self, document: &Value, force: bool, live: bool) -> Result<PutOutcome, TableError> {
1543        let record = self.stage_put_impl(document, force, live)?;
1544        self.flush()?;
1545        let warnings = self.eager_link_warnings(&record);
1546        Ok(PutOutcome { record, warnings })
1547    }
1548
1549    /// The staging half of [`Table::put`]: validates, claims the record's fields against everything
1550    /// else in flight and queues the write — but appends nothing and fires no trigger, both of
1551    /// which belong to the [`Table::flush`] that commits it.
1552    ///
1553    /// The record is returned as it will be stored, defaults and derived fields included, so a
1554    /// caller that answers its own user before the write is durable has the same answer `put` would
1555    /// have given. Until the flush, [`Table::get`] and [`Table::has`] read it back and
1556    /// [`Table::pending`] names the fields it claimed.
1557    pub fn stage_put(&mut self, document: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1558        self.stage_put_impl(document, force, true)
1559    }
1560
1561    fn stage_put_impl(&mut self, document: &Value, force: bool, live: bool) -> Result<HaysonRecord, TableError> {
1562        let accepted = self.accepted_document(document);
1563        let document = accepted.as_ref();
1564        self.assert_partition_stable_against_staged(document)?;
1565        let key = self.queue.stage_put(&self.schema, &self.registry, document, force)?;
1566        let Some(record) = self.queue.last_put_record().cloned() else {
1567            unreachable!("stage_put queues exactly one put entry")
1568        };
1569        self.write_image_fields(document)?;
1570        self.stage(key, Some(record.clone()), live)?;
1571        Ok(record)
1572    }
1573
1574    /// Records one staged write against `key`, carrying over the `old` and the position of an
1575    /// earlier write to the same key in this same window.
1576    fn stage(&mut self, key: String, record: Option<HaysonRecord>, live: bool) -> io::Result<()> {
1577        let (id, old) = match self.staged.get(&key) {
1578            Some(held) => (held.id, held.old.clone()),
1579            None => {
1580                self.next_op_id += 1;
1581                (self.next_op_id, self.committed(&key)?)
1582            }
1583        };
1584        self.staged.insert(key, StagedWrite { id, record, old, live });
1585        Ok(())
1586    }
1587
1588    /// Refuses a staged write that would move a key out of the partition another staged write just
1589    /// put it in — the registry-backed half of this rule lives in `WriteQueue::stage_put`, which
1590    /// cannot see a partition that only exists in the staging window.
1591    ///
1592    /// Costs a second `validate_record` for the write it inspects, so it only runs where it can
1593    /// possibly refuse anything: a partitioned table with a non-empty window.
1594    fn assert_partition_stable_against_staged(&self, document: &Value) -> Result<(), TableError> {
1595        if self.staged.is_empty() || !is_partitioned(&self.schema) {
1596            return Ok(());
1597        }
1598        let record = validate_record(&self.schema, document, ValidateOptions::default())?;
1599        let key = primary_key_of(&self.schema, &record)?;
1600        let Some(StagedWrite { record: Some(held), .. }) = self.staged.get(&key) else {
1601            return Ok(());
1602        };
1603        let held_part = partition_of(&self.schema, held)?;
1604        let part = partition_of(&self.schema, &record)?;
1605        if held_part == part {
1606            return Ok(());
1607        }
1608        Err(TableError::Partition(HaysonPartitionError::new(
1609            self.schema.table.clone(),
1610            self.schema.partition_by.clone().unwrap_or_default(),
1611            format!(
1612                "key \"{key}\" already lives in partition \"{held_part}\" — delete it before writing it into \"{part}\""
1613            ),
1614        )))
1615    }
1616
1617    /// Appends every write staged since the last flush, in staging order, then fires the triggers
1618    /// they earned — the port of the TS `WriteQueue.drain()`. A no-op on an empty window, so
1619    /// calling it before a read of committed state costs nothing when there is nothing in flight.
1620    ///
1621    /// Triggers fire once per key, with the record the key held when the window opened as their
1622    /// `old`: a key written twice before a flush is one change as far as an effect is concerned.
1623    pub fn flush(&mut self) -> Result<(), TableError> {
1624        if self.queue.depth() == 0 && self.staged.is_empty() {
1625            return Ok(());
1626        }
1627        self.commit()?;
1628
1629        let mut window: Vec<StagedWrite> = self.staged.drain().map(|(_, staged)| staged).collect();
1630        window.sort_by_key(|staged| staged.id);
1631        for staged in window {
1632            if !staged.live || (staged.record.is_none() && staged.old.is_none()) {
1633                continue;
1634            }
1635            let event = match (&staged.old, &staged.record) {
1636                (_, None) => TriggerEvent::Delete,
1637                (Some(_), Some(_)) => TriggerEvent::Update,
1638                (None, Some(_)) => TriggerEvent::Insert,
1639            };
1640            self.fire_same_table_triggers(event, staged.old.as_ref(), staged.record.as_ref())?;
1641        }
1642        Ok(())
1643    }
1644
1645    /// Read-merge-stage: reads the current record, validates `changes` as a partial record, merges
1646    /// it over the current one, then stages the merged document as a full `put` — mirrors the TS
1647    /// `Table.patch`, including the "primary key cannot be changed by patch" rule.
1648    pub fn patch(&mut self, key: &str, changes: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1649        self.patch_impl(key, changes, force, true).map(PutOutcome::into_record)
1650    }
1651
1652    /// Same as [`Table::patch`], but the outcome also carries diagnostics — see
1653    /// [`Table::put_with_warnings`] and [`PutOutcome`].
1654    pub fn patch_with_warnings(&mut self, key: &str, changes: &Value, force: bool) -> Result<PutOutcome, TableError> {
1655        self.patch_impl(key, changes, force, true)
1656    }
1657
1658    /// See [`Table::put_from_buffer`] — same reasoning, for a buffered patch.
1659    pub(crate) fn patch_from_buffer(
1660        &mut self,
1661        key: &str,
1662        changes: &Value,
1663        force: bool,
1664    ) -> Result<HaysonRecord, TableError> {
1665        self.patch_impl(key, changes, force, false).map(PutOutcome::into_record)
1666    }
1667
1668    fn patch_impl(&mut self, key: &str, changes: &Value, force: bool, live: bool) -> Result<PutOutcome, TableError> {
1669        let record = self.stage_patch_impl(key, changes, force, live)?;
1670        self.flush()?;
1671        let warnings = self.eager_link_warnings(&record);
1672        Ok(PutOutcome { record, warnings })
1673    }
1674
1675    /// The staging half of [`Table::patch`] — see [`Table::stage_put`] for what staging does and
1676    /// does not do. The record it merges over is the staged one when this key already has a write
1677    /// in flight, so two patches in one window compose instead of the later one dropping the
1678    /// earlier.
1679    ///
1680    /// Only the fields `changes` names are claimed, not the whole merged record: a patch of `age`
1681    /// must not refuse a patch of `bio` on the same key.
1682    pub fn stage_patch(&mut self, key: &str, changes: &Value, force: bool) -> Result<HaysonRecord, TableError> {
1683        self.stage_patch_impl(key, changes, force, true)
1684    }
1685
1686    fn stage_patch_impl(
1687        &mut self,
1688        key: &str,
1689        changes: &Value,
1690        force: bool,
1691        live: bool,
1692    ) -> Result<HaysonRecord, TableError> {
1693        let accepted = self.accepted_document(changes);
1694        let changes = accepted.as_ref();
1695        let (_, merged) = self.merged_patch(key, changes)?;
1696        let claim: Vec<String> = match changes.as_object() {
1697            Some(object) => object.keys().cloned().collect(),
1698            None => Vec::new(),
1699        };
1700        let document = Value::Object(merged.into_iter().collect());
1701        self.assert_partition_stable_against_staged(&document)?;
1702        let key = self
1703            .queue
1704            .stage_put_claiming(&self.schema, &self.registry, &document, &claim, force)?;
1705        let Some(record) = self.queue.last_put_record().cloned() else {
1706            unreachable!("stage_put_claiming queues exactly one put entry")
1707        };
1708        self.write_image_fields(changes)?;
1709        self.stage(key, Some(record.clone()), live)?;
1710        Ok(record)
1711    }
1712
1713    fn merged_patch(&self, key: &str, changes: &Value) -> Result<(HaysonRecord, HaysonRecord), TableError> {
1714        let base = self.get(key)?;
1715        self.merged_patch_over(key, base, changes)
1716    }
1717
1718    /// What a key holds as an operation in a unit of work is about to touch it: what the unit's own
1719    /// earlier operations left behind when they touched it, and the committed record otherwise.
1720    /// `Some(None)` in the shadow is a delete the unit staged earlier — absent, not "unshadowed".
1721    fn shadowed_state(&self, shadow: &UnitShadow, key: &str) -> Result<Option<HaysonRecord>, TableError> {
1722        match shadow.get(key) {
1723            Some(held) => Ok(held.clone()),
1724            None => Ok(self.get(key)?),
1725        }
1726    }
1727
1728    /// The merge half of a patch, over a caller-chosen base rather than the committed record. A unit
1729    /// of work merges over what its own earlier operations left behind, so a key put and then
1730    /// patched inside one unit composes instead of being refused as absent.
1731    fn merged_patch_over(
1732        &self,
1733        key: &str,
1734        base: Option<HaysonRecord>,
1735        changes: &Value,
1736    ) -> Result<(HaysonRecord, HaysonRecord), TableError> {
1737        let current = base.ok_or_else(|| {
1738            TableError::NotFound(HaysonNotFoundError {
1739                table: self.schema.table.clone(),
1740                key: key.to_string(),
1741            })
1742        })?;
1743        let partial = validate_record(
1744            &self.schema,
1745            changes,
1746            ValidateOptions {
1747                partial: true,
1748                now: None,
1749            },
1750        )?;
1751        if let Some(new_key) = partial.get(&self.schema.primary_key) {
1752            if value_as_key(new_key) != key {
1753                return Err(TableError::Schema(HaysonSchemaError(format!(
1754                    "the primary key of \"{key}\" cannot be changed by patch"
1755                ))));
1756            }
1757        }
1758
1759        let mut merged = current.clone();
1760        for (field, value) in &partial {
1761            merged.insert(field.clone(), value.clone());
1762        }
1763        Ok((current, merged))
1764    }
1765
1766    pub fn delete(&mut self, key: &str, force: bool) -> Result<bool, TableError> {
1767        self.delete_impl(key, force, true)
1768    }
1769
1770    /// See [`Table::put_from_buffer`] — same reasoning, for a buffered delete.
1771    pub(crate) fn delete_from_buffer(&mut self, key: &str, force: bool) -> Result<bool, TableError> {
1772        self.delete_impl(key, force, false)
1773    }
1774
1775    fn delete_impl(&mut self, key: &str, force: bool, live: bool) -> Result<bool, TableError> {
1776        let existed = self.stage_delete_impl(key, force, live)?;
1777        self.flush()?;
1778        Ok(existed)
1779    }
1780
1781    /// The staging half of [`Table::delete`] — see [`Table::stage_put`]. Answers whether a record
1782    /// was there to remove straight away, counting one staged in this same window, so the answer
1783    /// does not have to wait for the tombstone to be appended.
1784    pub fn stage_delete(&mut self, key: &str, force: bool) -> Result<bool, TableError> {
1785        self.stage_delete_impl(key, force, true)
1786    }
1787
1788    fn stage_delete_impl(&mut self, key: &str, force: bool, live: bool) -> Result<bool, TableError> {
1789        let old = self.get(key)?;
1790        let part = self.partition_holding(key)?;
1791        // NOTE: an unknown key names no partition file, so its tombstone would have nowhere to land.
1792        if part.is_none() && is_partitioned(&self.schema) {
1793            return Ok(false);
1794        }
1795        self.queue
1796            .stage_delete(key, force, part.as_deref().unwrap_or(UNPARTITIONED))?;
1797        self.stage(key.to_string(), None, live)?;
1798        Ok(old.is_some())
1799    }
1800
1801    /// The partition file `key` lives in — read off the staging window first, since a key first
1802    /// written there has no registry entry yet to name one.
1803    fn partition_holding(&self, key: &str) -> Result<Option<String>, TableError> {
1804        match self.staged.get(key) {
1805            Some(StagedWrite {
1806                record: Some(record), ..
1807            }) => Ok(Some(partition_of(&self.schema, record)?)),
1808            Some(_) => Ok(None),
1809            None => Ok(self.registry.part_of(key).map(str::to_string)),
1810        }
1811    }
1812
1813    /// Runs every declared trigger whose event matches and whose effect targets this same table —
1814    /// a cross-table effect only fires through [`crate::txn::UnitOfWork::commit`], which is the
1815    /// only caller holding every table of the database at once. The effect write itself never
1816    /// fires triggers (v1 has no chaining, see `catalog::TriggerEntry`).
1817    fn fire_same_table_triggers(
1818        &mut self,
1819        event: TriggerEvent,
1820        old: Option<&HaysonRecord>,
1821        new: Option<&HaysonRecord>,
1822    ) -> Result<(), TableError> {
1823        let triggers = self.triggers.clone();
1824        let firing = TriggerFiring { event, old, new };
1825        for trigger in matching(&triggers, &self.schema.table, event) {
1826            if trigger.effect.table != self.schema.table {
1827                continue;
1828            }
1829            let document = effect_document(&trigger.effect, &firing);
1830            self.write_effect(&document)?;
1831        }
1832        Ok(())
1833    }
1834
1835    fn write_effect(&mut self, document: &Value) -> Result<(), TableError> {
1836        self.queue.stage_put(&self.schema, &self.registry, document, true)?;
1837        self.commit()?;
1838        Ok(())
1839    }
1840
1841    /// Refuses a `search` against a table no field of which is marked `fulltext`: the index that
1842    /// would answer it does not exist, so an empty answer would be a lie rather than a result.
1843    fn assert_searchable(&self, spec: &QuerySpec) -> Result<(), TableError> {
1844        if spec.search.is_none() || self.registry.has_fulltext() {
1845            return Ok(());
1846        }
1847        Err(TableError::Schema(HaysonSchemaError(format!(
1848            "table \"{}\" has no field marked \"fulltext\": true — search is not available",
1849            self.schema.table
1850        ))))
1851    }
1852
1853    /// Runs `spec` against the committed log. Writes still in the staging window are **not** part
1854    /// of the answer: flushing needs `&mut self`, and every read that runs inside an already
1855    /// borrowed database (integrity checks, unit-of-work reads, `drop_field`'s residue scan) goes
1856    /// through this one. [`Table::query_following`] — what the bindings call — flushes first, and
1857    /// so is the one a caller staging its own writes wants.
1858    pub fn query(&self, spec: &QuerySpec) -> Result<QueryResult, TableError> {
1859        self.assert_searchable(spec)?;
1860        self.with_record_source(|source| execute_query(&self.registry, source, spec))
1861    }
1862
1863    /// [`Table::query`] as a unit of work will leave the table: `overlay` supplies the staged
1864    /// records and the keys staged for deletion, and `delta` the same writes expressed as a corpus
1865    /// change, so a `search` is scored over the corpus the unit will leave rather than the
1866    /// committed one. The plan carries a `PlanOverlay` saying what the staged half contributed.
1867    pub fn query_with_overlay(
1868        &self,
1869        spec: &QuerySpec,
1870        overlay: &dyn QueryOverlay,
1871        delta: &CorpusDelta,
1872    ) -> Result<QueryResult, TableError> {
1873        self.assert_searchable(spec)?;
1874        let scores = spec
1875            .search
1876            .as_ref()
1877            .filter(|_| self.registry.has_fulltext())
1878            .map(|search| self.registry.fulltext().search(&search.text, search.mode, Some(delta)));
1879        let access = plan_access_overlaid(&self.registry, spec, scores, overlay);
1880        self.with_record_source(|source| execute_access(&self.registry, source, spec, access, Some(overlay)))
1881    }
1882
1883    /// The single-field `exact`/`sorted` fields of this table — what a unit of work's overlay
1884    /// mirrors so an equality on one of them does not have to check every record it staged.
1885    pub fn indexed_fields(&self) -> Vec<String> {
1886        self.registry.indexed_fields()
1887    }
1888
1889    pub fn has_fulltext(&self) -> bool {
1890        self.registry.has_fulltext()
1891    }
1892
1893    /// The full-text tokens `record` would be indexed under, asked of a record the registry has not
1894    /// been told about yet — a unit of work's staged write, on its way into a [`CorpusDelta`].
1895    pub fn tokens_of(&self, record: &HaysonRecord) -> Vec<String> {
1896        self.registry.tokens_of(record)
1897    }
1898
1899    pub fn aggregate(&self, spec: &QuerySpec, aggregate: &AggregateSpec) -> Result<AggregateResult, TableError> {
1900        self.assert_searchable(spec)?;
1901        self.with_record_source(|source| execute_aggregate(&self.registry, source, spec, aggregate))
1902    }
1903
1904    /// Requires `aggregate.group_by` to be set — panics via `execute_grouped_aggregate` otherwise.
1905    pub fn aggregate_grouped(
1906        &self,
1907        spec: &QuerySpec,
1908        aggregate: &AggregateSpec,
1909    ) -> Result<GroupedAggregateResult, TableError> {
1910        self.assert_searchable(spec)?;
1911        self.with_record_source(|source| execute_grouped_aggregate(&self.registry, source, spec, aggregate))
1912    }
1913
1914    fn commit(&mut self) -> Result<Vec<WriteOutcome>, TableError> {
1915        // NOTE: every put/patch/delete lands here — dropping the cached document belongs with it.
1916        for key in self.queue.queued_keys() {
1917            self.cache.invalidate(key);
1918        }
1919        let schema = &self.schema;
1920        let cipher = self.cipher.as_ref();
1921        let cypher_fields = &self.cypher_fields;
1922        let seal = |key: &str, record: &HaysonRecord, carried: &[String]| -> Result<HaysonRecord, HaysonCipherError> {
1923            seal_record(schema, key, record, cipher, cypher_fields, carried)
1924        };
1925        let outcomes = self
1926            .queue
1927            .commit_sealed(&mut self.logs, &mut self.registry, &mut self.seq, true, &seal)?;
1928        self.note_writes(outcomes.len())?;
1929        Ok(outcomes)
1930    }
1931
1932    /// Highest `_seq` this table has reserved — after `Table::open`, this is the highest `_seq`
1933    /// actually durable in the log; a live table also counts `_seq` values reserved but not yet
1934    /// applied by [`Table::prepare_txn_ops`].
1935    pub fn current_seq(&self) -> u64 {
1936        self.seq
1937    }
1938
1939    /// Step 3 ("prepare") of a multi-table unit of work: validates every op against this table's
1940    /// schema and unique constraints, reserves a `_seq` and serializes a log line for each — but
1941    /// appends nothing. Bypasses `WriteQueue`/`PendingMap` entirely (unlike `put`/`patch`/`delete`):
1942    /// the caller's exclusive `&mut Table` for the lifetime of the unit already rules out any other
1943    /// writer, so there is nothing left for a pending-claim to guard against.
1944    ///
1945    /// A rejected op aborts the whole call with nothing appended — but `_seq` values already
1946    /// reserved for earlier ops in this call stay burned, same as the TS reference.
1947    pub fn prepare_txn_ops(&mut self, ops: &[TxnOp]) -> Result<Vec<PreparedTxnEntry>, TableError> {
1948        // NOTE: a unit of work reserves _seq itself, so nothing may still be waiting to take one.
1949        self.flush()?;
1950        let mut prepared = Vec::with_capacity(ops.len());
1951        let now = chrono::Utc::now().timestamp_millis();
1952        // NOTE: a key put earlier in this same unit has no registry entry yet to name its partition.
1953        let mut staged_parts: HashMap<String, String> = HashMap::new();
1954        // NOTE: unique is judged against the ops prepared earlier in this same call, not just the log.
1955        let mut shadow = UnitShadow::new();
1956        for op in ops {
1957            let (key, old, record) = match op {
1958                TxnOp::Delete(key) => (key.clone(), self.shadowed_state(&shadow, key)?, None),
1959                TxnOp::Put(document) => {
1960                    let accepted = self.accepted_document(document);
1961                    let record = validate_record(&self.schema, accepted.as_ref(), ValidateOptions::default())?;
1962                    let key = primary_key_of(&self.schema, &record)?;
1963                    assert_unique_in_unit(&self.schema, &self.registry, &key, &record, &shadow)?;
1964                    let old = self.shadowed_state(&shadow, &key)?;
1965                    (key, old, Some(record))
1966                }
1967                TxnOp::Patch(key, changes) => {
1968                    let accepted = self.accepted_document(changes);
1969                    let base = self.shadowed_state(&shadow, key)?;
1970                    let (old, merged) = self.merged_patch_over(key, base, accepted.as_ref())?;
1971                    assert_unique_in_unit(&self.schema, &self.registry, key, &merged, &shadow)?;
1972                    (key.clone(), Some(old), Some(merged))
1973                }
1974            };
1975
1976            let part = match &record {
1977                Some(record) => {
1978                    let part = partition_of(&self.schema, record)?;
1979                    assert_partition_stable(&self.schema, &self.registry, &key, &part)?;
1980                    if let Some(held) = staged_parts.get(&key) {
1981                        if held != &part {
1982                            return Err(TableError::Partition(HaysonPartitionError::new(
1983                                self.schema.table.clone(),
1984                                self.schema.partition_by.clone().unwrap_or_default(),
1985                                format!("key \"{key}\" already lives in partition \"{held}\" — delete it before writing it into \"{part}\""),
1986                            )));
1987                        }
1988                    }
1989                    staged_parts.insert(key.clone(), part.clone());
1990                    part
1991                }
1992                None => {
1993                    let held = staged_parts
1994                        .get(&key)
1995                        .cloned()
1996                        .or_else(|| self.registry.part_of(&key).map(str::to_string));
1997                    match held {
1998                        Some(part) => part,
1999                        // NOTE: an unknown key names no partition file, so its tombstone is dropped.
2000                        None if is_partitioned(&self.schema) => continue,
2001                        None => UNPARTITIONED.to_string(),
2002                    }
2003                }
2004            };
2005
2006            shadow.insert(key.clone(), record.clone());
2007            self.seq += 1;
2008            let meta = LogMeta {
2009                key: key.clone(),
2010                op: if record.is_some() { LogOp::Put } else { LogOp::Del },
2011                seq: self.seq,
2012                ts: now,
2013            };
2014            // NOTE: a unit of work carries no already-sealed value — every op of one comes from a document.
2015            let sealed = record
2016                .as_ref()
2017                .map(|rec| seal_record(&self.schema, &key, rec, self.cipher.as_ref(), &self.cypher_fields, &[]))
2018                .transpose()?;
2019            let line = serialize_entry(&meta, sealed.as_ref());
2020            prepared.push(PreparedTxnEntry {
2021                key,
2022                seq: self.seq,
2023                line,
2024                old,
2025                record,
2026                part,
2027            });
2028        }
2029        Ok(prepared)
2030    }
2031
2032    /// Step 5 ("write") of a multi-table unit of work: appends the lines a prior
2033    /// `prepare_txn_ops` call already serialized, in order, in one `append` per partition they
2034    /// touch — then `fsync`s and updates the registry so the table reflects them immediately.
2035    pub fn apply_txn_entries(&mut self, entries: Vec<PreparedTxnEntry>) -> io::Result<()> {
2036        if entries.is_empty() {
2037            return Ok(());
2038        }
2039        let applied = entries.len();
2040        let mut slots: Vec<Option<Slot>> = vec![None; entries.len()];
2041        for part in ordered_parts(&entries) {
2042            let positions: Vec<usize> = entries
2043                .iter()
2044                .enumerate()
2045                .filter(|(_, entry)| entry.part == part)
2046                .map(|(at, _)| at)
2047                .collect();
2048            let lines: Vec<String> = positions.iter().map(|at| entries[*at].line.clone()).collect();
2049            for (at, slot) in positions.into_iter().zip(self.logs.append(&part, &lines)?) {
2050                slots[at] = Some(slot);
2051            }
2052        }
2053        self.logs.sync()?;
2054        for (entry, slot) in entries.into_iter().zip(slots) {
2055            let Some(slot) = slot else { continue };
2056            self.cache.invalidate(&entry.key);
2057            match entry.record {
2058                Some(record) => self
2059                    .registry
2060                    .apply_put_in(&entry.key, &record, slot, entry.seq, &entry.part),
2061                None => self
2062                    .registry
2063                    .apply_delete_in(&entry.key, slot.length, entry.seq, &entry.part),
2064            }
2065        }
2066        self.note_writes(applied)
2067    }
2068
2069    /// Finishes an unfinished journal at reopen: appends every already-serialized `(seq, line)`
2070    /// pair whose `seq` is above what this table's log already holds, in order, then `fsync`s and
2071    /// updates the registry — the same "seq higher than the log" rule the TS engine uses. Returns
2072    /// how many lines were actually appended (0 when this table already has everything).
2073    pub fn recover_txn_lines(&mut self, entries: &[(u64, String)]) -> Result<usize, TableError> {
2074        let missing: Vec<&(u64, String)> = entries.iter().filter(|(seq, _)| *seq > self.seq).collect();
2075        if missing.is_empty() {
2076            return Ok(0);
2077        }
2078        let applied = missing.len();
2079        // NOTE: parse before appending — a journalled line's partition is only in the line itself.
2080        let mut pending: Vec<(String, LogEntry, String)> = Vec::with_capacity(applied);
2081        for (_, line) in &missing {
2082            let path = self.logs.path_of(UNPARTITIONED).to_string_lossy().into_owned();
2083            let parsed = parse_entry(line, &path, 0)
2084                .map_err(|error| TableError::Io(io::Error::new(io::ErrorKind::InvalidData, error)))?;
2085            let part = match (&parsed.meta.op, &parsed.data) {
2086                (LogOp::Put, Some(record)) => partition_of(&self.schema, record)?,
2087                (LogOp::Put, None) => {
2088                    return Err(TableError::Io(io::Error::new(
2089                        io::ErrorKind::InvalidData,
2090                        format!("journalled put for \"{}\" carries no record", parsed.meta.key),
2091                    )))
2092                }
2093                (LogOp::Del, _) => self
2094                    .registry
2095                    .part_of(&parsed.meta.key)
2096                    .unwrap_or(UNPARTITIONED)
2097                    .to_string(),
2098            };
2099            pending.push((part, parsed, line.clone()));
2100        }
2101
2102        for (part, parsed, line) in pending {
2103            let slot = self.logs.append(&part, std::slice::from_ref(&line))?[0];
2104            self.cache.invalidate(&parsed.meta.key);
2105            match parsed.data {
2106                Some(record) => self
2107                    .registry
2108                    .apply_put_in(&parsed.meta.key, &record, slot, parsed.meta.seq, &part),
2109                None => self
2110                    .registry
2111                    .apply_delete_in(&parsed.meta.key, slot.length, parsed.meta.seq, &part),
2112            }
2113            self.seq = self.seq.max(parsed.meta.seq);
2114        }
2115        self.logs.sync()?;
2116        // NOTE: mirrors TS #redo — recovery always resnapshots, never leans on the op threshold.
2117        self.snapshot_current = false;
2118        self.snapshot()?;
2119        Ok(applied)
2120    }
2121}
2122
2123/// The partitions `entries` name, in first-appearance order — the order their appends run in, so a
2124/// unit of work touching two partitions writes them in the order it staged them.
2125fn ordered_parts(entries: &[PreparedTxnEntry]) -> Vec<String> {
2126    let mut parts: Vec<String> = Vec::new();
2127    for entry in entries {
2128        if !parts.iter().any(|part| part == &entry.part) {
2129            parts.push(entry.part.clone());
2130        }
2131    }
2132    parts
2133}
2134
2135/// The directory a partitioned table's log files live in: `<dir>/<table>/`, beside the
2136/// `<dir>/<table>.jsonl` an unpartitioned table would use.
2137fn partition_dir_of(log_path: &Path, table: &str) -> PathBuf {
2138    match log_path.parent() {
2139        Some(parent) => parent.join(table),
2140        None => PathBuf::from(table),
2141    }
2142}
2143
2144/// The log files of a table: the single file at `path` when its schema declares no `partitionBy`,
2145/// and `<dir>/<table>/<value>.jsonl` when it does — in which case a flat log left over from before
2146/// the schema declared partitions is moved into that layout first (`storage::ensure_partitioned`).
2147///
2148/// A readonly open never runs that move. Between a catalog that already declares `partitionBy` and
2149/// the move that has not run yet, the records still live in the flat log, and this handle takes them
2150/// as it finds them: no rename, no deletion, no partition directory brought into being. The choice
2151/// mirrors `database::present_logs_of`, which decides on the same terms which logs such an open is
2152/// about to take on — a partition directory holding logs wins, an empty one falls back to the flat
2153/// log beside it.
2154fn open_logs(schema: &TableSchema, path: &Path, readonly: bool) -> io::Result<LogSet> {
2155    if !is_partitioned(schema) {
2156        return LogSet::flat(path, readonly);
2157    }
2158    let partition_dir = partition_dir_of(path, &schema.table);
2159    if readonly {
2160        if partition_names_in(&partition_dir).is_empty() && path.exists() {
2161            return LogSet::flat(path, true);
2162        }
2163        return LogSet::partitioned(partition_dir, true);
2164    }
2165    ensure_partitioned(path, &partition_dir, schema)?;
2166    LogSet::partitioned(partition_dir, readonly)
2167}
2168
2169/// One operation staged in a multi-table unit of work, ready for [`Table::prepare_txn_ops`].
2170pub enum TxnOp {
2171    Put(Value),
2172    Patch(String, Value),
2173    Delete(String),
2174}
2175
2176/// A validated, `_seq`-assigned, serialized log line produced by [`Table::prepare_txn_ops`] —
2177/// nothing on disk yet, but everything needed to journal it and, later, to append it.
2178pub struct PreparedTxnEntry {
2179    key: String,
2180    seq: u64,
2181    line: String,
2182    old: Option<HaysonRecord>,
2183    record: Option<HaysonRecord>,
2184    /// The partition file this line lands in — [`UNPARTITIONED`] on an unpartitioned table.
2185    part: String,
2186}
2187
2188impl PreparedTxnEntry {
2189    pub fn seq(&self) -> u64 {
2190        self.seq
2191    }
2192
2193    pub fn line(&self) -> &str {
2194        &self.line
2195    }
2196
2197    pub fn old(&self) -> Option<&HaysonRecord> {
2198        self.old.as_ref()
2199    }
2200
2201    pub fn record(&self) -> Option<&HaysonRecord> {
2202        self.record.as_ref()
2203    }
2204
2205    /// `Insert` when no `old` record existed, `Update` otherwise — a `Delete` is never produced
2206    /// here, since a caller telling insert/update apart from delete already knows which `TxnOp`
2207    /// it staged; use it against `TxnOp::Put`/`TxnOp::Patch` results only.
2208    pub fn upsert_event(&self) -> crate::catalog::TriggerEvent {
2209        if self.old.is_some() {
2210            crate::catalog::TriggerEvent::Update
2211        } else {
2212            crate::catalog::TriggerEvent::Insert
2213        }
2214    }
2215}
2216
2217#[cfg(test)]
2218mod tests {
2219    use serde_json::json;
2220    use tempfile::tempdir;
2221
2222    use super::*;
2223    use crate::query::builder::QueryBuilder;
2224    use crate::query::predicates::ComparisonOp;
2225    use crate::schema::parse_schema;
2226
2227    fn users_schema() -> TableSchema {
2228        parse_schema(&json!({
2229            "schemaVersion": 1,
2230            "table": "users",
2231            "primaryKey": "id",
2232            "fields": {
2233                "id": { "type": "string" },
2234                "name": { "type": "string", "required": true },
2235                "age": { "type": "int", "index": "sorted" },
2236                "email": { "type": "string", "unique": true },
2237            },
2238        }))
2239        .unwrap()
2240    }
2241
2242    fn open_table() -> (tempfile::TempDir, Table) {
2243        let dir = tempdir().unwrap();
2244        let path = dir.path().join("users.jsonl");
2245        let table = Table::open(users_schema(), &path).unwrap();
2246        (dir, table)
2247    }
2248
2249    #[test]
2250    fn a_table_set_to_sync_never_flushes_its_writes_without_a_single_fsync() {
2251        let (_dir, mut table) = open_table();
2252        table.set_sync(CatalogSyncMode::Never);
2253
2254        table
2255            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2256            .unwrap();
2257        table
2258            .put(&json!({ "id": "u2", "name": "Piotr", "age": 41 }), false)
2259            .unwrap();
2260        table.flush().unwrap();
2261
2262        assert_eq!(table.sync_mode(), CatalogSyncMode::Never);
2263        assert_eq!(table.logs.log_of(UNPARTITIONED).unwrap().fsyncs(), 0);
2264        assert_eq!(table.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
2265        assert_eq!(table.get("u2").unwrap().unwrap().get("name"), Some(&json!("Piotr")));
2266    }
2267
2268    #[test]
2269    fn a_table_nobody_configured_still_fsyncs_every_flush() {
2270        let (_dir, mut table) = open_table();
2271
2272        assert_eq!(table.sync_mode(), CatalogSyncMode::Commit);
2273        table
2274            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2275            .unwrap();
2276        table.flush().unwrap();
2277
2278        assert_eq!(table.logs.log_of(UNPARTITIONED).unwrap().fsyncs(), 1);
2279    }
2280
2281    #[test]
2282    fn history_hands_back_every_version_of_one_key_oldest_first() {
2283        let (dir, mut table) = open_table();
2284        table
2285            .put(
2286                &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "a@example.test" }),
2287                false,
2288            )
2289            .unwrap();
2290        table
2291            .put(
2292                &json!({ "id": "u2", "name": "Piotr", "age": 41, "email": "p@example.test" }),
2293                false,
2294            )
2295            .unwrap();
2296        table.patch("u1", &json!({ "age": 31 }), false).unwrap();
2297        table.delete("u1", false).unwrap();
2298        table
2299            .put(
2300                &json!({ "id": "u1", "name": "Ania", "age": 32, "email": "a@example.test" }),
2301                false,
2302            )
2303            .unwrap();
2304
2305        let history = table.history("u1").unwrap();
2306
2307        assert_eq!(history.scanned, vec![dir.path().join("users.jsonl")]);
2308        let shape: Vec<(u64, LogOp, Option<&Value>)> = history
2309            .entries
2310            .iter()
2311            .map(|entry| (entry.seq, entry.op, entry.document.as_ref().and_then(|d| d.get("age"))))
2312            .collect();
2313        assert_eq!(
2314            shape,
2315            vec![
2316                (1, LogOp::Put, Some(&json!(30))),
2317                (3, LogOp::Put, Some(&json!(31))),
2318                (4, LogOp::Del, None),
2319                (5, LogOp::Put, Some(&json!(32))),
2320            ]
2321        );
2322        assert!(history.entries.iter().all(|entry| entry.partition == UNPARTITIONED));
2323        assert!(history.entries.iter().all(|entry| entry.ts > 0));
2324        assert_eq!(table.history("u2").unwrap().entries.len(), 1);
2325        assert!(table.history("nobody").unwrap().entries.is_empty());
2326    }
2327
2328    #[test]
2329    fn history_reads_the_line_the_offsets_it_reports_point_at() {
2330        let (_dir, mut table) = open_table();
2331        table
2332            .put(
2333                &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "a@example.test" }),
2334                false,
2335            )
2336            .unwrap();
2337        table.patch("u1", &json!({ "age": 31 }), false).unwrap();
2338
2339        let history = table.history("u1").unwrap();
2340        let log = table.logs.log_of(UNPARTITIONED).unwrap();
2341
2342        for entry in &history.entries {
2343            let slot = Slot {
2344                offset: entry.offset,
2345                length: entry.length,
2346            };
2347            let line: Value = serde_json::from_str(&log.read_slice(&slot).unwrap()).unwrap();
2348            assert_eq!(line.get("_seq"), Some(&json!(entry.seq)));
2349        }
2350    }
2351
2352    #[test]
2353    fn history_does_not_see_a_write_that_is_still_staged() {
2354        let (_dir, mut table) = open_table();
2355        table
2356            .stage_put(
2357                &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "a@example.test" }),
2358                false,
2359            )
2360            .unwrap();
2361
2362        assert!(table.history("u1").unwrap().entries.is_empty());
2363
2364        table.flush().unwrap();
2365
2366        assert_eq!(table.history("u1").unwrap().entries.len(), 1);
2367    }
2368
2369    #[test]
2370    fn a_table_nobody_configured_refuses_a_write_naming_an_undeclared_field() {
2371        let (_dir, mut table) = open_table();
2372        assert_eq!(table.validation(), CatalogValidation::Strict);
2373
2374        let error = table
2375            .put(&json!({ "id": "u1", "name": "Ania", "nickname": "Anka" }), false)
2376            .unwrap_err();
2377
2378        assert!(error.to_string().contains("nickname"), "{error}");
2379        assert!(error.to_string().contains("is not declared in the schema"), "{error}");
2380        assert_eq!(table.get("u1").unwrap(), None);
2381    }
2382
2383    #[test]
2384    fn a_warn_table_takes_the_write_drops_the_undeclared_field_and_says_which() {
2385        let dir = tempdir().unwrap();
2386        let path = dir.path().join("users.jsonl");
2387        let mut table = Table::open(users_schema(), &path).unwrap();
2388        table.set_validation(CatalogValidation::Warn);
2389        let document = json!({ "id": "u1", "name": "Ania", "age": 30, "nickname": "Anka" });
2390
2391        let record = table.put(&document, false).unwrap();
2392
2393        assert_eq!(record.get("name"), Some(&json!("Ania")));
2394        assert_eq!(record.get("nickname"), None);
2395        assert_eq!(table.get("u1").unwrap().unwrap().get("nickname"), None);
2396        assert_eq!(
2397            table.dropped_field_warnings(&document),
2398            vec!["field \"nickname\": is not declared in the schema, dropped from the write".to_string()]
2399        );
2400        let written = fs::read_to_string(&path).unwrap();
2401        assert!(!written.contains("nickname"), "{written}");
2402        assert!(!written.contains("Anka"), "{written}");
2403    }
2404
2405    #[test]
2406    fn a_warn_table_still_refuses_a_bad_type_a_missing_required_field_and_a_reserved_key() {
2407        let (_dir, mut table) = open_table();
2408        table.set_validation(CatalogValidation::Warn);
2409
2410        let bad_type = table
2411            .put(&json!({ "id": "u1", "name": "Ania", "age": "thirty" }), false)
2412            .unwrap_err();
2413        let missing = table.put(&json!({ "id": "u2" }), false).unwrap_err();
2414        let reserved = table
2415            .put(&json!({ "id": "u3", "name": "Ola", "_seq": 7 }), false)
2416            .unwrap_err();
2417
2418        assert!(bad_type.to_string().contains("expected an integer"), "{bad_type}");
2419        assert!(missing.to_string().contains("is required"), "{missing}");
2420        assert!(reserved.to_string().contains("reserved"), "{reserved}");
2421    }
2422
2423    #[test]
2424    fn a_warn_patch_merges_the_declared_changes_and_drops_the_undeclared_one() {
2425        let (_dir, mut table) = open_table();
2426        table.set_validation(CatalogValidation::Warn);
2427        table
2428            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2429            .unwrap();
2430
2431        let record = table
2432            .patch("u1", &json!({ "age": 31, "nickname": "Anka" }), false)
2433            .unwrap();
2434
2435        assert_eq!(record.get("age"), Some(&json!(31)));
2436        assert_eq!(record.get("nickname"), None);
2437        assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(31)));
2438    }
2439
2440    #[test]
2441    fn a_warn_table_prepares_a_unit_of_work_line_without_the_undeclared_field() {
2442        let (_dir, mut table) = open_table();
2443        table.set_validation(CatalogValidation::Warn);
2444
2445        let prepared = table
2446            .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Ania", "nickname": "Anka" }))])
2447            .unwrap();
2448
2449        assert_eq!(prepared.len(), 1);
2450        assert!(!prepared[0].line.contains("nickname"), "{}", prepared[0].line);
2451        assert_eq!(prepared[0].record.as_ref().unwrap().get("nickname"), None);
2452    }
2453
2454    #[test]
2455    fn dropped_field_warnings_answer_nothing_on_a_strict_table_or_a_document_with_no_stray_field() {
2456        let (_dir, mut table) = open_table();
2457        let document = json!({ "id": "u1", "name": "Ania", "nickname": "Anka" });
2458
2459        assert!(table.dropped_field_warnings(&document).is_empty());
2460
2461        table.set_validation(CatalogValidation::Warn);
2462        assert!(table
2463            .dropped_field_warnings(&json!({ "id": "u1", "name": "Ania" }))
2464            .is_empty());
2465        assert_eq!(table.dropped_field_warnings(&document).len(), 1);
2466    }
2467
2468    fn deprecating(age: &str) -> BTreeMap<String, String> {
2469        BTreeMap::from([("age".to_string(), age.to_string())])
2470    }
2471
2472    #[test]
2473    fn deprecated_field_warnings_name_the_marked_field_the_document_touches_and_quote_its_note() {
2474        let (_dir, mut table) = open_table();
2475        table.set_deprecated_fields(deprecating("use \"bornAt\" instead"));
2476
2477        table
2478            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2479            .unwrap();
2480
2481        assert_eq!(
2482            table.deprecated_field_warnings(&json!({ "id": "u1", "name": "Ania", "age": 30 })),
2483            vec!["field \"age\": is deprecated — use \"bornAt\" instead".to_string()]
2484        );
2485        assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(30)));
2486    }
2487
2488    #[test]
2489    fn deprecated_field_warnings_stay_quiet_for_a_document_touching_no_marked_field() {
2490        let (_dir, mut table) = open_table();
2491        table.set_deprecated_fields(deprecating("use \"bornAt\" instead"));
2492
2493        assert!(table
2494            .deprecated_field_warnings(&json!({ "id": "u1", "name": "Ania" }))
2495            .is_empty());
2496        assert!(table.deprecated_field_warnings(&json!({ "name": "Ola" })).is_empty());
2497    }
2498
2499    /// `parse_catalog` refuses `deprecated: ""`, so only a hand-built handle can get here — the
2500    /// guard exists because `set_deprecated_fields` is public and cannot assume its caller.
2501    #[test]
2502    fn a_field_handed_over_with_an_empty_note_warns_with_the_field_alone() {
2503        let (_dir, mut table) = open_table();
2504        table.set_deprecated_fields(deprecating(""));
2505
2506        assert_eq!(
2507            table.deprecated_field_warnings(&json!({ "id": "u1", "age": 30 })),
2508            vec!["field \"age\": is deprecated".to_string()]
2509        );
2510    }
2511
2512    #[test]
2513    fn deprecated_field_warnings_answer_the_same_in_either_validation_mode_and_on_every_write() {
2514        let (_dir, mut table) = open_table();
2515        table.set_deprecated_fields(deprecating("use \"bornAt\" instead"));
2516        let document = json!({ "id": "u1", "name": "Ania", "age": 30 });
2517        let expected = vec!["field \"age\": is deprecated — use \"bornAt\" instead".to_string()];
2518
2519        assert_eq!(table.validation(), CatalogValidation::Strict);
2520        table.put(&document, false).unwrap();
2521        assert_eq!(table.deprecated_field_warnings(&document), expected);
2522
2523        table.set_validation(CatalogValidation::Warn);
2524        table.put(&document, false).unwrap();
2525        assert_eq!(table.deprecated_field_warnings(&document), expected);
2526        assert!(table.dropped_field_warnings(&document).is_empty());
2527    }
2528
2529    #[test]
2530    fn a_table_nobody_marked_answers_no_deprecation_warning_at_all() {
2531        let (_dir, table) = open_table();
2532
2533        assert!(table.deprecated_fields().is_empty());
2534        assert!(table
2535            .deprecated_field_warnings(&json!({ "id": "u1", "name": "Ania", "age": 30 }))
2536            .is_empty());
2537    }
2538
2539    #[test]
2540    fn what_a_sync_never_table_wrote_is_still_there_after_reopening_it() {
2541        let dir = tempdir().unwrap();
2542        let path = dir.path().join("users.jsonl");
2543        {
2544            let mut table = Table::open(users_schema(), &path).unwrap();
2545            table.set_sync(CatalogSyncMode::Never);
2546            table
2547                .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2548                .unwrap();
2549            table.flush().unwrap();
2550        }
2551
2552        let reopened = Table::open(users_schema(), &path).unwrap();
2553        assert_eq!(reopened.get("u1").unwrap().unwrap().get("age"), Some(&json!(30)));
2554    }
2555
2556    #[test]
2557    fn put_then_get_round_trips_the_record() {
2558        let (_dir, mut table) = open_table();
2559        table
2560            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2561            .unwrap();
2562
2563        let fetched = table.get("u1").unwrap().unwrap();
2564        assert_eq!(fetched.get("name"), Some(&json!("Ania")));
2565        assert_eq!(fetched.get("age"), Some(&json!(30)));
2566    }
2567
2568    #[test]
2569    fn get_of_an_unknown_key_is_none() {
2570        let (_dir, table) = open_table();
2571        assert_eq!(table.get("missing").unwrap(), None);
2572    }
2573
2574    #[test]
2575    fn put_delete_get_leaves_nothing_behind() {
2576        let (_dir, mut table) = open_table();
2577        table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2578        let existed = table.delete("u1", false).unwrap();
2579        assert!(existed);
2580        assert_eq!(table.get("u1").unwrap(), None);
2581    }
2582
2583    #[test]
2584    fn delete_of_an_unknown_key_reports_it_did_not_exist() {
2585        let (_dir, mut table) = open_table();
2586        let existed = table.delete("ghost", false).unwrap();
2587        assert!(!existed);
2588    }
2589
2590    #[test]
2591    fn patch_merges_only_the_given_fields() {
2592        let (_dir, mut table) = open_table();
2593        table
2594            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2595            .unwrap();
2596
2597        let patched = table.patch("u1", &json!({ "age": 31 }), false).unwrap();
2598        assert_eq!(patched.get("name"), Some(&json!("Ania")));
2599        assert_eq!(patched.get("age"), Some(&json!(31)));
2600
2601        let fetched = table.get("u1").unwrap().unwrap();
2602        assert_eq!(fetched, patched);
2603    }
2604
2605    fn self_audit_trigger() -> TriggerEntry {
2606        TriggerEntry {
2607            name: "users_insert_self_audit".into(),
2608            table: "users".into(),
2609            event: TriggerEvent::Insert,
2610            effect: crate::catalog::TriggerEffect {
2611                table: "users".into(),
2612                fields: vec![
2613                    crate::catalog::TriggerFieldMapping {
2614                        target: "id".into(),
2615                        source: crate::catalog::TriggerValueSource::Constant(json!("audit-u1")),
2616                    },
2617                    crate::catalog::TriggerFieldMapping {
2618                        target: "name".into(),
2619                        source: crate::catalog::TriggerValueSource::NewField("name".into()),
2620                    },
2621                ],
2622            },
2623        }
2624    }
2625
2626    #[test]
2627    fn a_live_put_fires_a_same_table_trigger_right_after_the_write_it_fired_on() {
2628        let (_dir, mut table) = open_table();
2629        table.set_triggers(vec![self_audit_trigger()]);
2630
2631        table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2632
2633        assert!(table.get("u1").unwrap().is_some());
2634        let effect = table.get("audit-u1").unwrap().unwrap();
2635        assert_eq!(effect.get("name"), Some(&json!("Ania")));
2636    }
2637
2638    #[test]
2639    fn a_trigger_only_fires_for_the_event_it_declares() {
2640        let (_dir, mut table) = open_table();
2641        table.set_triggers(vec![self_audit_trigger()]);
2642
2643        table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2644        table.patch("u1", &json!({ "name": "Anna" }), false).unwrap();
2645
2646        // the insert-only trigger did not re-fire on the update.
2647        assert_eq!(
2648            table.get("audit-u1").unwrap().unwrap().get("name"),
2649            Some(&json!("Ania"))
2650        );
2651    }
2652
2653    #[test]
2654    fn patch_of_an_unknown_key_is_an_error() {
2655        let (_dir, mut table) = open_table();
2656        let err = table.patch("ghost", &json!({ "age": 1 }), false).unwrap_err();
2657        assert!(matches!(err, TableError::NotFound(_)));
2658    }
2659
2660    #[test]
2661    fn patch_cannot_change_the_primary_key() {
2662        let (_dir, mut table) = open_table();
2663        table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2664        let err = table.patch("u1", &json!({ "id": "u2" }), false).unwrap_err();
2665        assert!(matches!(err, TableError::Schema(_)));
2666    }
2667
2668    #[test]
2669    fn repeated_puts_on_the_same_key_leave_only_the_latest_version() {
2670        let (_dir, mut table) = open_table();
2671        table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2672        table.put(&json!({ "id": "u1", "name": "Zosia" }), false).unwrap();
2673        table.put(&json!({ "id": "u1", "name": "Kasia" }), false).unwrap();
2674
2675        let fetched = table.get("u1").unwrap().unwrap();
2676        assert_eq!(fetched.get("name"), Some(&json!("Kasia")));
2677    }
2678
2679    /// No field of `users_schema` is marked `fulltext`, so nothing could have scored the text — an
2680    /// empty answer would claim the table holds no match, which is a different statement.
2681    #[test]
2682    fn a_search_against_a_table_with_no_fulltext_field_is_refused_rather_than_answered_empty() {
2683        let (_dir, mut table) = open_table();
2684        table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
2685
2686        let spec = QueryBuilder::new()
2687            .search("anything", crate::registry::fulltext_index::SearchMode::All)
2688            .spec();
2689        let refusal = table.query(&spec).unwrap_err();
2690
2691        assert!(
2692            matches!(&refusal, TableError::Schema(_)),
2693            "a table with no fulltext field is a schema refusal, not an io failure: {refusal:?}"
2694        );
2695        assert!(
2696            refusal.to_string().contains("has no field marked \"fulltext\""),
2697            "the message is the one the TS package raised: {refusal}"
2698        );
2699    }
2700
2701    #[test]
2702    fn query_delegates_to_the_planner_and_matches_it_directly() {
2703        let (_dir, mut table) = open_table();
2704        table
2705            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2706            .unwrap();
2707        table
2708            .put(&json!({ "id": "u2", "name": "Piotr", "age": 24 }), false)
2709            .unwrap();
2710
2711        let spec = QueryBuilder::new().r#where("age", ComparisonOp::Gt, json!(25)).spec();
2712        let via_table = table.query(&spec).unwrap();
2713
2714        let mut source = LogSetRecordSource::new(&table.registry, &table.logs);
2715        let direct = execute_query(&table.registry, &mut source, &spec).unwrap();
2716
2717        assert_eq!(via_table, direct);
2718        assert_eq!(via_table.keys, vec!["u1".to_string()]);
2719    }
2720
2721    #[test]
2722    fn aggregate_delegates_to_the_planner_and_matches_it_directly() {
2723        let (_dir, mut table) = open_table();
2724        table
2725            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
2726            .unwrap();
2727        table
2728            .put(&json!({ "id": "u2", "name": "Piotr", "age": 24 }), false)
2729            .unwrap();
2730
2731        let (spec, aggregate) = QueryBuilder::new().count().sum("age").avg("age").build_aggregate();
2732        let via_table = table.aggregate(&spec, &aggregate).unwrap();
2733
2734        let mut source = LogSetRecordSource::new(&table.registry, &table.logs);
2735        let direct =
2736            crate::query::aggregate::execute_aggregate(&table.registry, &mut source, &spec, &aggregate).unwrap();
2737
2738        assert_eq!(via_table, direct);
2739        assert_eq!(via_table.count, 2);
2740        assert_eq!(via_table.sum["age"], 54.0);
2741        assert_eq!(via_table.avg["age"], Some(27.0));
2742    }
2743
2744    fn open_table_with_snapshot_every(snapshot_every: u64) -> (tempfile::TempDir, Table, std::path::PathBuf) {
2745        let dir = tempdir().unwrap();
2746        let log_path = dir.path().join("users.jsonl");
2747        let snapshot_path = dir.path().join("users.idx");
2748        let (table, _) =
2749            Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", snapshot_every)
2750                .unwrap();
2751        (dir, table, snapshot_path)
2752    }
2753
2754    #[test]
2755    fn writes_a_snapshot_once_the_operation_threshold_is_crossed() {
2756        let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(3);
2757        assert!(!snapshot_path.exists());
2758
2759        for n in 0..3 {
2760            table
2761                .put(
2762                    &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2763                    false,
2764                )
2765                .unwrap();
2766        }
2767
2768        let snapshot = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
2769        assert_eq!(snapshot.header.watermark, table.logs.total_size());
2770        assert_eq!(snapshot.entries.len(), 3);
2771        assert!(!table.needs_snapshot());
2772    }
2773
2774    #[test]
2775    fn a_snapshot_written_mid_stream_does_not_disturb_later_reads_and_writes() {
2776        let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(2);
2777
2778        table
2779            .put(
2780                &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
2781                false,
2782            )
2783            .unwrap();
2784        table
2785            .put(
2786                &json!({ "id": "u2", "name": "Piotr", "age": 24, "email": "u2@x.com" }),
2787                false,
2788            )
2789            .unwrap();
2790        assert!(snapshot_path.exists());
2791
2792        table
2793            .put(
2794                &json!({ "id": "u3", "name": "Kasia", "age": 40, "email": "u3@x.com" }),
2795                false,
2796            )
2797            .unwrap();
2798        let existed = table.delete("u1", false).unwrap();
2799        assert!(existed);
2800
2801        assert_eq!(table.get("u1").unwrap(), None);
2802        assert_eq!(table.get("u3").unwrap().unwrap().get("name"), Some(&json!("Kasia")));
2803    }
2804
2805    #[test]
2806    fn compaction_replaces_the_snapshot_with_one_that_pairs_with_the_rewritten_log() {
2807        let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(0);
2808        for n in 0..4 {
2809            table
2810                .put(
2811                    &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2812                    false,
2813                )
2814                .unwrap();
2815        }
2816        table.delete("u0", false).unwrap();
2817        table.delete("u1", false).unwrap();
2818        table.snapshot().unwrap();
2819        let stale = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
2820        assert!(
2821            stale.header.dead_bytes > 0,
2822            "the seed really did leave dead bytes behind"
2823        );
2824
2825        let result = table.compact().unwrap();
2826
2827        assert!(result.reclaimed_bytes > 0, "the compaction really did shrink the log");
2828        let fresh = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
2829        assert_eq!(fresh.header.watermark, table.logs.total_size());
2830        assert_eq!(
2831            fresh.header.watermark,
2832            fs::metadata(table.logs.log_of("").unwrap().path()).unwrap().len()
2833        );
2834        assert!(fresh.header.watermark < stale.header.watermark);
2835        assert_eq!(fresh.header.dead_bytes, 0);
2836        assert_eq!(fresh.entries.len(), 2);
2837        assert!(!table.needs_snapshot());
2838    }
2839
2840    #[test]
2841    fn the_generation_counts_compactions_and_survives_a_restart_through_the_snapshot_header() {
2842        let dir = tempdir().unwrap();
2843        let log_path = dir.path().join("users.jsonl");
2844        let snapshot_path = dir.path().join("users.idx");
2845
2846        {
2847            let (mut table, _) =
2848                Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2849            assert_eq!(
2850                table.stats().gen,
2851                0,
2852                "a table opened over a log with no snapshot starts at zero"
2853            );
2854            for n in 0..3 {
2855                table
2856                    .put(
2857                        &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2858                        false,
2859                    )
2860                    .unwrap();
2861            }
2862            table.delete("u0", false).unwrap();
2863            table.compact().unwrap();
2864            assert_eq!(table.stats().gen, 1);
2865            table.compact().unwrap();
2866            assert_eq!(table.stats().gen, 2);
2867            assert_eq!(
2868                crate::checkpoint::read_snapshot(&snapshot_path)
2869                    .unwrap()
2870                    .unwrap()
2871                    .header
2872                    .gen,
2873                2,
2874                "the snapshot carries the generation it describes"
2875            );
2876        }
2877
2878        let (mut reopened, outcome) =
2879            Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2880        assert_eq!(outcome, RestoreOutcome::Restored);
2881        assert_eq!(
2882            reopened.stats().gen,
2883            2,
2884            "the restart carries the generation over instead of starting again"
2885        );
2886        reopened.compact().unwrap();
2887        assert_eq!(reopened.stats().gen, 3);
2888        assert_eq!(
2889            crate::checkpoint::read_snapshot(&snapshot_path)
2890                .unwrap()
2891                .unwrap()
2892                .header
2893                .gen,
2894            3
2895        );
2896    }
2897
2898    /// The snapshot header grew `gen` after this engine had already written snapshots without it,
2899    /// so a file from before that has to stay readable — and the counter has to start somewhere
2900    /// sane rather than throwing the whole cache away over a missing hint.
2901    #[test]
2902    fn a_snapshot_written_before_the_header_carried_a_generation_still_restores_and_starts_at_zero() {
2903        let dir = tempdir().unwrap();
2904        let log_path = dir.path().join("users.jsonl");
2905        let snapshot_path = dir.path().join("users.idx");
2906
2907        {
2908            let (mut table, _) =
2909                Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2910            for n in 0..3 {
2911                table
2912                    .put(
2913                        &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2914                        false,
2915                    )
2916                    .unwrap();
2917            }
2918            table.compact().unwrap();
2919            assert_eq!(table.stats().gen, 1);
2920        }
2921
2922        let written = fs::read_to_string(&snapshot_path).unwrap();
2923        let (header_line, entry_lines) = written.split_once('\n').unwrap();
2924        let mut header: serde_json::Map<String, Value> = serde_json::from_str(header_line).unwrap();
2925        assert!(
2926            header.remove("gen").is_some(),
2927            "the header this engine writes carries the field to remove"
2928        );
2929        fs::write(
2930            &snapshot_path,
2931            format!("{}\n{entry_lines}", serde_json::to_string(&header).unwrap()),
2932        )
2933        .unwrap();
2934
2935        let (mut reopened, outcome) =
2936            Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2937        assert_eq!(
2938            outcome,
2939            RestoreOutcome::Restored,
2940            "an old snapshot is still a usable cache"
2941        );
2942        assert_eq!(reopened.len(), 3);
2943        assert_eq!(reopened.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
2944        assert_eq!(reopened.stats().gen, 0);
2945        reopened.compact().unwrap();
2946        assert_eq!(
2947            reopened.stats().gen,
2948            1,
2949            "counting resumes from the default, and lands in the rewritten header"
2950        );
2951        assert_eq!(
2952            crate::checkpoint::read_snapshot(&snapshot_path)
2953                .unwrap()
2954                .unwrap()
2955                .header
2956                .gen,
2957            1
2958        );
2959    }
2960
2961    #[test]
2962    fn reads_agree_before_and_after_a_restart_that_follows_a_compaction() {
2963        let dir = tempdir().unwrap();
2964        let log_path = dir.path().join("users.jsonl");
2965        let snapshot_path = dir.path().join("users.idx");
2966        let spec = QueryBuilder::new()
2967            .r#where("name", ComparisonOp::Eq, json!("Ania"))
2968            .spec();
2969
2970        let (before, after_compaction) = {
2971            let (mut table, _) =
2972                Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2973            for n in 0..5 {
2974                table
2975                    .put(
2976                        &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
2977                        false,
2978                    )
2979                    .unwrap();
2980            }
2981            table.patch("u2", &json!({ "age": 31 }), false).unwrap();
2982            table.delete("u4", false).unwrap();
2983            table.snapshot().unwrap();
2984            let before = table.query(&spec).unwrap().keys;
2985
2986            table.compact().unwrap();
2987            let after = table.query(&spec).unwrap().keys;
2988            (before, after)
2989        };
2990
2991        let (reopened, outcome) =
2992            Table::open_with_snapshot(users_schema(), &log_path, &snapshot_path, "schema-hash", 0).unwrap();
2993        assert_eq!(
2994            outcome,
2995            RestoreOutcome::Restored,
2996            "the snapshot compaction wrote pairs with the new log"
2997        );
2998        assert_eq!(
2999            after_compaction, before,
3000            "compaction changed neither the set nor the order of live keys"
3001        );
3002        assert_eq!(reopened.query(&spec).unwrap().keys, before);
3003        assert_eq!(reopened.len(), 4);
3004        assert_eq!(reopened.get("u2").unwrap().unwrap().get("age"), Some(&json!(31)));
3005        assert_eq!(reopened.get("u4").unwrap(), None);
3006    }
3007
3008    #[test]
3009    fn zero_disables_periodic_snapshotting() {
3010        let (_dir, mut table, snapshot_path) = open_table_with_snapshot_every(0);
3011        for n in 0..10 {
3012            table
3013                .put(
3014                    &json!({ "id": format!("u{n}"), "name": "Ania", "age": 30, "email": format!("u{n}@x.com") }),
3015                    false,
3016                )
3017                .unwrap();
3018        }
3019        assert!(!snapshot_path.exists());
3020        assert!(table.needs_snapshot());
3021    }
3022
3023    #[test]
3024    fn a_table_opened_without_a_snapshot_path_never_writes_one() {
3025        let (_dir, mut table) = open_table();
3026        table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3027        table.snapshot().unwrap();
3028        assert!(!table.needs_snapshot());
3029    }
3030
3031    /// Two handles over the same log file, as two processes of one directory would have — nothing
3032    /// at the `Table` level keeps them apart, the writer lock lives a layer up in `Database`.
3033    fn two_handles() -> (tempfile::TempDir, Table, Table) {
3034        let dir = tempdir().unwrap();
3035        let path = dir.path().join("users.jsonl");
3036        let writer = Table::open(users_schema(), &path).unwrap();
3037        let follower = Table::open(users_schema(), &path).unwrap();
3038        (dir, writer, follower)
3039    }
3040
3041    #[test]
3042    fn refresh_on_a_log_nobody_touched_reports_no_change_and_leaves_the_registry_alone() {
3043        let (_dir, mut table) = open_table();
3044        table
3045            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3046            .unwrap();
3047
3048        let first = table.refresh().unwrap();
3049        let second = table.refresh().unwrap();
3050
3051        assert_eq!(first, RefreshOutcome::Unchanged);
3052        assert_eq!(
3053            second,
3054            RefreshOutcome::Unchanged,
3055            "a handle is not behind its own writes"
3056        );
3057        assert!(!first.changed());
3058        assert_eq!(table.len(), 1);
3059        assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(30)));
3060    }
3061
3062    #[test]
3063    fn refresh_applies_the_records_another_handle_appended_to_the_same_log() {
3064        let (_dir, mut writer, mut follower) = two_handles();
3065        writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3066        assert_eq!(
3067            follower.get("u1").unwrap(),
3068            None,
3069            "a handle sees nothing until it looks again"
3070        );
3071
3072        let outcome = follower.refresh().unwrap();
3073
3074        assert_eq!(outcome, RefreshOutcome::CaughtUp { entries: 1 });
3075        assert_eq!(follower.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
3076        assert_eq!(follower.len(), 1);
3077        assert_eq!(follower.stats().seq, writer.stats().seq);
3078    }
3079
3080    #[test]
3081    fn refresh_applies_a_foreign_delete_as_well_as_a_foreign_write() {
3082        let (_dir, mut writer, mut follower) = two_handles();
3083        writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3084        writer.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3085        follower.refresh().unwrap();
3086        writer.delete("u1", false).unwrap();
3087
3088        assert_eq!(follower.refresh().unwrap(), RefreshOutcome::CaughtUp { entries: 1 });
3089        assert!(!follower.has("u1"));
3090        assert!(follower.has("u2"));
3091        assert_eq!(follower.len(), 1);
3092    }
3093
3094    #[test]
3095    fn refresh_rebuilds_after_another_handle_compacted_the_log_under_it() {
3096        let (_dir, mut writer, mut follower) = two_handles();
3097        writer
3098            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3099            .unwrap();
3100        writer.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3101        writer.patch("u1", &json!({ "age": 31 }), false).unwrap();
3102        writer.delete("u2", false).unwrap();
3103        follower.refresh().unwrap();
3104        let before = follower.stats().log_bytes;
3105
3106        writer.compact().unwrap();
3107
3108        let outcome = follower.refresh().unwrap();
3109        assert_eq!(outcome, RefreshOutcome::Reloaded);
3110        assert!(
3111            follower.stats().log_bytes < before,
3112            "the compaction really did shrink the file"
3113        );
3114        assert_eq!(follower.len(), 1);
3115        assert_eq!(follower.get("u1").unwrap().unwrap().get("age"), Some(&json!(31)));
3116        assert_eq!(follower.get("u2").unwrap(), None);
3117    }
3118
3119    /// The case a size watermark alone cannot see: after the compaction the writer appends enough
3120    /// to push the rewritten file past where the follower's watermark stood, so the file looks like
3121    /// it merely grew. Only the inode says otherwise — which is why this test is unix-only; on a
3122    /// host that reports no inode `LogFile::replaced_by` degrades to the shrink it can see.
3123    #[cfg(unix)]
3124    #[test]
3125    fn refresh_catches_a_foreign_compaction_even_when_the_log_ends_up_longer_than_before() {
3126        let (_dir, mut writer, mut follower) = two_handles();
3127        for round in 0..6 {
3128            writer
3129                .put(&json!({ "id": "u1", "name": format!("round {round}") }), false)
3130                .unwrap();
3131        }
3132        follower.refresh().unwrap();
3133        let watermark = follower.stats().log_bytes;
3134
3135        writer.compact().unwrap();
3136        for id in ["u2", "u3", "u4", "u5", "u6"] {
3137            writer
3138                .put(&json!({ "id": id, "name": "after the compaction" }), false)
3139                .unwrap();
3140        }
3141        assert!(
3142            writer.stats().log_bytes > watermark,
3143            "the log grew past where the follower stood"
3144        );
3145
3146        let outcome = follower.refresh().unwrap();
3147
3148        assert_eq!(outcome, RefreshOutcome::Reloaded);
3149        assert_eq!(follower.len(), 6);
3150        assert_eq!(
3151            follower.get("u1").unwrap().unwrap().get("name"),
3152            Some(&json!("round 5"))
3153        );
3154        assert_eq!(
3155            follower.get("u6").unwrap().unwrap().get("name"),
3156            Some(&json!("after the compaction"))
3157        );
3158    }
3159
3160    #[test]
3161    fn a_reload_keeps_serving_queries_from_the_declared_indexes_it_was_built_with() {
3162        let dir = tempdir().unwrap();
3163        let path = dir.path().join("users.jsonl");
3164        let declared = vec![ExpandedIndex {
3165            name: "users_name_email".into(),
3166            fields: vec!["name".into(), "email".into()],
3167            kind: crate::catalog::CatalogIndexKind::Exact,
3168            unique: false,
3169            where_: None,
3170            expire_after_seconds: None,
3171        }];
3172        let mut writer = Table::open_with_declared_indexes(users_schema(), &path, &declared).unwrap();
3173        let mut follower = Table::open_with_declared_indexes(users_schema(), &path, &declared).unwrap();
3174        writer
3175            .put(&json!({ "id": "u1", "name": "Ania", "email": "a@x.pl" }), false)
3176            .unwrap();
3177        follower.refresh().unwrap();
3178        writer.compact().unwrap();
3179
3180        assert_eq!(follower.refresh().unwrap(), RefreshOutcome::Reloaded);
3181        let spec = QueryBuilder::new()
3182            .r#where("name", ComparisonOp::Eq, json!("Ania"))
3183            .r#where("email", ComparisonOp::Eq, json!("a@x.pl"))
3184            .spec();
3185        let found = follower.query(&spec).unwrap();
3186        assert_eq!(found.keys, vec!["u1".to_string()]);
3187        assert_eq!(
3188            found.plan.index.as_deref(),
3189            Some("users_name_email"),
3190            "a rebuilt registry kept the declared index"
3191        );
3192    }
3193
3194    /// The write side of the same identity check: a data directory kept under version control gets
3195    /// its files swapped out by `git checkout` while a long-running process still holds them open.
3196    /// Same bytes, new inode — only a host that reports inodes can tell, hence unix-only.
3197    #[cfg(unix)]
3198    #[test]
3199    fn a_write_after_the_log_was_swapped_out_under_the_table_is_refused_rather_than_lost() {
3200        let (dir, mut table) = open_table();
3201        let path = dir.path().join("users.jsonl");
3202        table
3203            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3204            .unwrap();
3205        let swapped = dir.path().join("users.jsonl.swap");
3206        std::fs::copy(&path, &swapped).unwrap();
3207        std::fs::rename(&swapped, &path).unwrap();
3208
3209        let error = table.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap_err();
3210
3211        let TableError::Io(io) = &error else {
3212            panic!("expected an io refusal, got {error}");
3213        };
3214        let replaced = io
3215            .get_ref()
3216            .and_then(|inner| inner.downcast_ref::<crate::errors::HaysonLogReplacedError>())
3217            .expect("the refusal names the swapped log");
3218        assert_eq!(replaced.path, path.display().to_string());
3219
3220        let reopened = Table::open(users_schema(), &path).unwrap();
3221        assert_eq!(reopened.len(), 1, "nothing landed in the orphaned inode");
3222        assert_eq!(reopened.get("u2").unwrap(), None);
3223    }
3224
3225    #[test]
3226    fn refresh_opens_a_partition_file_another_handle_created_and_reads_it_whole() {
3227        let dir = tempdir().unwrap();
3228        let path = dir.path().join("notes.jsonl");
3229        let schema = parse_schema(&json!({
3230            "schemaVersion": 1,
3231            "table": "notes",
3232            "primaryKey": "id",
3233            "partitionBy": "kind",
3234            "fields": { "id": { "type": "string" }, "kind": { "type": "string", "required": true } },
3235        }))
3236        .unwrap();
3237        let mut writer = Table::open(schema.clone(), &path).unwrap();
3238        let mut follower = Table::open(schema, &path).unwrap();
3239        writer.put(&json!({ "id": "a1", "kind": "adr" }), false).unwrap();
3240        writer.put(&json!({ "id": "n1", "kind": "note" }), false).unwrap();
3241
3242        let outcome = follower.refresh().unwrap();
3243
3244        assert_eq!(outcome, RefreshOutcome::CaughtUp { entries: 2 });
3245        let mut keys: Vec<&String> = follower.keys().collect();
3246        keys.sort();
3247        assert_eq!(keys, vec!["a1", "n1"]);
3248    }
3249
3250    /// The gate a binding asks under a shared lock before deciding whether it has to take an
3251    /// exclusive one. It has to answer exactly what `follow_if_due` would then do, or the cheap
3252    /// check and the expensive one disagree and a follower silently stops following.
3253    #[test]
3254    fn follow_is_due_only_where_follow_if_due_would_reach_for_the_files() {
3255        let (_dir, mut writer, mut follower) = two_handles();
3256        assert!(!follower.follow_is_due(), "no policy is the writable handle's default");
3257        assert_eq!(follower.follow_if_due().unwrap(), None);
3258
3259        follower.set_follow(Some(FollowPolicy {
3260            interval: Duration::ZERO,
3261        }));
3262        assert!(follower.follow_is_due(), "a zero interval is due on every read");
3263
3264        follower.set_follow(Some(FollowPolicy {
3265            interval: Duration::from_secs(60),
3266        }));
3267        assert!(
3268            follower.follow_is_due(),
3269            "and a handle that never looked is due whatever it is"
3270        );
3271        writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3272        assert_eq!(
3273            follower.follow_if_due().unwrap(),
3274            Some(RefreshOutcome::CaughtUp { entries: 1 })
3275        );
3276
3277        assert!(
3278            !follower.follow_is_due(),
3279            "the interval has not elapsed since that catch-up"
3280        );
3281        writer.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3282        assert_eq!(follower.follow_if_due().unwrap(), None);
3283        assert!(!follower.has("u2"), "which is the whole point of the interval");
3284    }
3285
3286    /// The other gate: `flush` is a no-op on an empty staging window, and this has to be true
3287    /// exactly when it would not be, so a read that flushes first can skip the flush.
3288    #[test]
3289    fn has_staged_writes_answers_whether_a_flush_would_do_anything() {
3290        let (_dir, mut table) = open_table();
3291        assert!(!table.has_staged_writes(), "a table nobody staged against");
3292
3293        table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3294        assert!(!table.has_staged_writes(), "put stages and flushes inside the one call");
3295
3296        table.stage_put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3297        assert!(table.has_staged_writes());
3298        assert_eq!(table.stats().queue_depth, 1);
3299
3300        table.flush().unwrap();
3301        assert!(
3302            !table.has_staged_writes(),
3303            "and the window is empty again once it lands"
3304        );
3305    }
3306
3307    #[test]
3308    fn keys_has_len_and_stats_report_the_live_records_not_the_bytes_behind_them() {
3309        let (_dir, mut table) = open_table();
3310        assert!(table.is_empty());
3311        table
3312            .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3313            .unwrap();
3314        table.put(&json!({ "id": "u2", "name": "Piotr" }), false).unwrap();
3315        table.patch("u1", &json!({ "age": 31 }), false).unwrap();
3316        table.delete("u2", false).unwrap();
3317
3318        let mut keys: Vec<&String> = table.keys().collect();
3319        keys.sort();
3320        let stats = table.stats();
3321
3322        assert_eq!(keys, vec!["u1"]);
3323        assert_eq!(table.len(), 1, "a tombstoned key is not a record");
3324        assert!(!table.is_empty());
3325        assert!(table.has("u1"));
3326        assert!(!table.has("u2"));
3327        assert!(!table.has("never-written"));
3328        assert_eq!(stats.records, 1);
3329        assert!(
3330            stats.dead_bytes > 0,
3331            "the superseded version and the tombstone are dead bytes"
3332        );
3333        assert_eq!(stats.log_bytes, stats.live_bytes + stats.dead_bytes);
3334        assert_eq!(stats.seq, 4, "one seq per appended line, tombstone included");
3335        assert_eq!(stats.gen, 0);
3336    }
3337
3338    #[test]
3339    fn pending_is_empty_between_calls_because_every_write_commits_inside_its_own_call() {
3340        let (_dir, mut table) = open_table();
3341        table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3342
3343        assert!(table.pending("u1").is_empty());
3344        assert!(table.pending("never-written").is_empty());
3345        assert_eq!(table.stats().queue_depth, 0);
3346    }
3347
3348    /// The staging window `put`/`patch`/`delete` open and close inside their own call, driven a
3349    /// step at a time — the shape the N-API binding uses to answer a caller before its write is
3350    /// durable, and the semantics `packages/hayson/test/table.test.ts` measures.
3351    mod staging {
3352        use super::*;
3353
3354        #[test]
3355        fn a_staged_put_is_read_back_before_it_is_a_record() {
3356            let (_dir, mut table) = open_table();
3357            let staged = table.stage_put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3358
3359            assert_eq!(staged.get("name"), Some(&json!("Ania")));
3360            assert_eq!(table.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
3361            assert!(table.has("u1"));
3362            assert_eq!(table.len(), 0, "size counts the registry, never the window");
3363            assert!(table.keys().next().is_none(), "and so does keys()");
3364            assert_eq!(table.stats().queue_depth, 1);
3365            assert_eq!(
3366                table.pending("u1"),
3367                HashSet::from(["id".to_string(), "name".to_string()])
3368            );
3369
3370            table.flush().unwrap();
3371
3372            assert_eq!(table.len(), 1);
3373            assert_eq!(table.stats().queue_depth, 0);
3374            assert!(table.pending("u1").is_empty());
3375        }
3376
3377        #[test]
3378        fn a_staged_delete_answers_gone_before_its_tombstone_is_written() {
3379            let (_dir, mut table) = open_table();
3380            table.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3381
3382            assert!(
3383                table.stage_delete("u1", false).unwrap(),
3384                "it says what it removed right away"
3385            );
3386            assert_eq!(table.get("u1").unwrap(), None);
3387            assert!(!table.has("u1"));
3388            assert_eq!(table.len(), 1, "the record is a record until the tombstone lands");
3389
3390            table.flush().unwrap();
3391
3392            assert_eq!(table.len(), 0);
3393            assert_eq!(table.get("u1").unwrap(), None);
3394        }
3395
3396        #[test]
3397        fn a_staged_write_outranks_a_peers_committed_one_on_the_same_key() {
3398            let (_dir, mut writer, mut follower) = two_handles();
3399            follower
3400                .stage_put(&json!({ "id": "u1", "name": "Ania" }), false)
3401                .unwrap();
3402            writer.put(&json!({ "id": "u1", "name": "Zosia" }), false).unwrap();
3403
3404            assert!(follower.refresh().unwrap().changed());
3405            assert_eq!(
3406                follower.get("u1").unwrap().unwrap().get("name"),
3407                Some(&json!("Ania")),
3408                "the foreign write landed in the registry, but the window is read first"
3409            );
3410
3411            follower.flush().unwrap();
3412
3413            assert_eq!(follower.get("u1").unwrap().unwrap().get("name"), Some(&json!("Ania")));
3414            assert_eq!(follower.len(), 1);
3415        }
3416
3417        #[test]
3418        fn a_staged_patch_claims_the_fields_it_changes_and_no_others() {
3419            let (_dir, mut table) = open_table();
3420            table
3421                .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3422                .unwrap();
3423
3424            table.stage_patch("u1", &json!({ "age": 31 }), false).unwrap();
3425            table.stage_patch("u1", &json!({ "name": "Anna" }), false).unwrap();
3426            assert_eq!(
3427                table.pending("u1"),
3428                HashSet::from(["age".to_string(), "name".to_string()])
3429            );
3430
3431            let refused = table.stage_patch("u1", &json!({ "age": 32 }), false).unwrap_err();
3432            assert!(matches!(refused, TableError::Conflict(_)));
3433
3434            table.stage_patch("u1", &json!({ "age": 44 }), true).unwrap();
3435            table.flush().unwrap();
3436
3437            let found = table.get("u1").unwrap().unwrap();
3438            assert_eq!(
3439                found.get("age"),
3440                Some(&json!(44)),
3441                "the forced patch is the one that stuck"
3442            );
3443            assert_eq!(
3444                found.get("name"),
3445                Some(&json!("Anna")),
3446                "and it merged over the other one"
3447            );
3448        }
3449
3450        #[test]
3451        fn a_read_of_committed_state_flushes_the_window_first() {
3452            let (_dir, mut table) = open_table();
3453            table
3454                .stage_put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
3455                .unwrap();
3456
3457            let spec = QueryBuilder::new().r#where("age", ComparisonOp::Gt, json!(25)).spec();
3458            assert_eq!(table.query_following(&spec).unwrap().keys, vec!["u1".to_string()]);
3459            assert_eq!(table.stats().queue_depth, 0);
3460
3461            table
3462                .stage_put(&json!({ "id": "u2", "name": "Piotr", "age": 24 }), false)
3463                .unwrap();
3464            let (counted, aggregate) = QueryBuilder::new().count().build_aggregate();
3465            assert_eq!(table.aggregate_following(&counted, &aggregate).unwrap().count, 2);
3466            assert_eq!(table.stats().queue_depth, 0);
3467
3468            table
3469                .stage_put(&json!({ "id": "u3", "name": "Kasia", "age": 51 }), false)
3470                .unwrap();
3471            table.compact().unwrap();
3472
3473            assert_eq!(
3474                table.stats().queue_depth,
3475                0,
3476                "compaction would have rewritten the log without it"
3477            );
3478            assert_eq!(table.len(), 3);
3479            assert_eq!(table.get("u3").unwrap().unwrap().get("name"), Some(&json!("Kasia")));
3480        }
3481
3482        #[test]
3483        fn a_trigger_fires_on_the_flush_rather_than_on_the_staging() {
3484            let (_dir, mut table) = open_table();
3485            table.set_triggers(vec![self_audit_trigger()]);
3486
3487            table.stage_put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3488            assert_eq!(
3489                table.get("audit-u1").unwrap(),
3490                None,
3491                "an uncommitted write has no effect yet"
3492            );
3493
3494            table.flush().unwrap();
3495
3496            assert_eq!(
3497                table.get("audit-u1").unwrap().unwrap().get("name"),
3498                Some(&json!("Ania"))
3499            );
3500        }
3501    }
3502
3503    #[test]
3504    fn stats_counts_a_rebuild_as_a_generation() {
3505        let (_dir, mut writer, mut follower) = two_handles();
3506        writer.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
3507        follower.refresh().unwrap();
3508        assert_eq!(follower.stats().gen, 0);
3509
3510        writer.compact().unwrap();
3511        follower.refresh().unwrap();
3512
3513        assert_eq!(follower.stats().gen, 1);
3514    }
3515
3516    fn docs_schema() -> TableSchema {
3517        parse_schema(&json!({
3518            "schemaVersion": 1,
3519            "table": "docs",
3520            "primaryKey": "id",
3521            "fields": {
3522                "id": { "type": "string" },
3523                "cover": { "type": "image" },
3524            },
3525        }))
3526        .unwrap()
3527    }
3528
3529    fn open_docs_table() -> (tempfile::TempDir, Table) {
3530        let dir = tempdir().unwrap();
3531        let path = dir.path().join("docs.jsonl");
3532        let table = Table::open(docs_schema(), &path).unwrap();
3533        (dir, table)
3534    }
3535
3536    #[test]
3537    fn a_put_with_an_image_field_writes_its_bytes_under_the_default_images_directory() {
3538        let (dir, mut table) = open_docs_table();
3539        let record = table
3540            .put(
3541                &json!({ "id": "d1", "cover": { "content": "bG9nby1ieXRlcw==", "virtualPath": "/images/logo.png" } }),
3542                false,
3543            )
3544            .unwrap();
3545        let physical_path = record
3546            .get("cover")
3547            .unwrap()
3548            .get("physicalPath")
3549            .unwrap()
3550            .as_str()
3551            .unwrap();
3552
3553        assert_eq!(table.image_dir(), dir.path().join("docs.images"));
3554        let on_disk = fs::read(table.image_dir().join(physical_path)).unwrap();
3555        assert_eq!(on_disk, b"logo-bytes");
3556    }
3557
3558    #[test]
3559    fn a_second_record_with_the_same_image_content_does_not_rewrite_the_existing_file() {
3560        let (_dir, mut table) = open_docs_table();
3561        table
3562            .put(
3563                &json!({ "id": "d1", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/a.png" } }),
3564                false,
3565            )
3566            .unwrap();
3567        let file_path = table.image_dir().join(physical_path_of(b"same-bytes"));
3568        let written_at = fs::metadata(&file_path).unwrap().modified().unwrap();
3569
3570        table
3571            .put(
3572                &json!({ "id": "d2", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/b.png" } }),
3573                false,
3574            )
3575            .unwrap();
3576
3577        assert_eq!(fs::metadata(&file_path).unwrap().modified().unwrap(), written_at);
3578        assert_eq!(fs::read(&file_path).unwrap(), b"same-bytes");
3579    }
3580
3581    #[test]
3582    fn the_images_directory_is_created_automatically_on_first_write() {
3583        let (_dir, mut table) = open_docs_table();
3584        assert!(!table.image_dir().exists());
3585
3586        table
3587            .put(
3588                &json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
3589                false,
3590            )
3591            .unwrap();
3592
3593        assert!(table.image_dir().is_dir());
3594    }
3595
3596    #[test]
3597    fn a_patch_that_does_not_touch_the_image_field_still_validates() {
3598        let (_dir, mut table) = open_docs_table();
3599        table
3600            .put(
3601                &json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
3602                false,
3603            )
3604            .unwrap();
3605
3606        let patched = table.patch("d1", &json!({}), false).unwrap();
3607        assert_eq!(
3608            patched.get("cover").unwrap().get("virtualPath"),
3609            Some(&json!("/images/a.png"))
3610        );
3611    }
3612
3613    fn pages_schema() -> TableSchema {
3614        parse_schema(&json!({
3615            "schemaVersion": 1,
3616            "table": "pages",
3617            "primaryKey": "id",
3618            "fields": {
3619                "id": { "type": "string" },
3620                "page": { "type": "markdown" },
3621            },
3622        }))
3623        .unwrap()
3624    }
3625
3626    fn open_pages_table() -> (tempfile::TempDir, Table) {
3627        let dir = tempdir().unwrap();
3628        let path = dir.path().join("pages.jsonl");
3629        let table = Table::open(pages_schema(), &path).unwrap();
3630        (dir, table)
3631    }
3632
3633    fn pages_with_cover_schema() -> TableSchema {
3634        parse_schema(&json!({
3635            "schemaVersion": 1,
3636            "table": "pages",
3637            "primaryKey": "id",
3638            "fields": {
3639                "id": { "type": "string" },
3640                "page": { "type": "markdown" },
3641                "cover": { "type": "image" },
3642            },
3643        }))
3644        .unwrap()
3645    }
3646
3647    fn open_pages_with_cover_table() -> (tempfile::TempDir, Table) {
3648        let dir = tempdir().unwrap();
3649        let path = dir.path().join("pages.jsonl");
3650        let table = Table::open(pages_with_cover_schema(), &path).unwrap();
3651        (dir, table)
3652    }
3653
3654    /// `page` is a link target, `notes` deliberately is not — the pair every `addressable: false`
3655    /// test needs in one table.
3656    fn open_pages_with_notes_table() -> (tempfile::TempDir, Table) {
3657        let schema = parse_schema(&json!({
3658            "schemaVersion": 1,
3659            "table": "pages",
3660            "primaryKey": "id",
3661            "fields": {
3662                "id": { "type": "string" },
3663                "page": { "type": "markdown" },
3664                "notes": { "type": "markdown", "addressable": false },
3665            },
3666        }))
3667        .unwrap();
3668        let dir = tempdir().unwrap();
3669        let path = dir.path().join("pages.jsonl");
3670        let table = Table::open(schema, &path).unwrap();
3671        (dir, table)
3672    }
3673
3674    #[test]
3675    fn a_non_addressable_field_links_to_an_addressable_one_and_the_link_resolves() {
3676        let (_dir, mut table) = open_pages_with_notes_table();
3677
3678        let outcome = table
3679            .put_with_warnings(
3680                &json!({
3681                    "id": "a",
3682                    "page": { "content": "the target", "virtualPath": "/docs/a.md" },
3683                    "notes": { "content": "see [a](/docs/a.md)" },
3684                }),
3685                false,
3686            )
3687            .unwrap();
3688
3689        assert!(
3690            outcome.warnings.is_empty(),
3691            "the content of a non-addressable field is still scanned, and this link resolves: {:?}",
3692            outcome.warnings
3693        );
3694        assert_eq!(
3695            table.broken_links_of("a").unwrap(),
3696            Vec::new(),
3697            "asking on demand has to agree"
3698        );
3699    }
3700
3701    #[test]
3702    fn a_broken_link_in_a_non_addressable_field_is_still_reported_against_that_field() {
3703        let (_dir, mut table) = open_pages_with_notes_table();
3704
3705        let outcome = table
3706            .put_with_warnings(
3707                &json!({
3708                    "id": "a",
3709                    "page": { "content": "", "virtualPath": "/docs/a.md" },
3710                    "notes": { "content": "see [gone](/docs/missing.md)" },
3711                }),
3712                false,
3713            )
3714            .unwrap();
3715
3716        assert_eq!(
3717            outcome.warnings,
3718            vec![BrokenLink {
3719                field: "notes".to_string(),
3720                path: "/docs/missing.md".to_string()
3721            }]
3722        );
3723    }
3724
3725    #[test]
3726    fn nothing_can_link_to_a_non_addressable_field_because_it_holds_no_virtual_path() {
3727        let (_dir, mut table) = open_pages_with_notes_table();
3728        table
3729            .put(
3730                &json!({
3731                    "id": "a",
3732                    "page": { "content": "", "virtualPath": "/docs/a.md" },
3733                    "notes": { "content": "unreachable", "virtualPath": "/notes/a.md" },
3734                }),
3735                false,
3736            )
3737            .unwrap();
3738
3739        let outcome = table
3740            .put_with_warnings(
3741                &json!({
3742                    "id": "b",
3743                    "page": { "content": "[notes](/notes/a.md)", "virtualPath": "/docs/b.md" },
3744                }),
3745                false,
3746            )
3747            .unwrap();
3748
3749        assert_eq!(
3750            table.registry.virtual_path_fields().cloned().collect::<Vec<String>>(),
3751            vec!["page".to_string()],
3752            "only the addressable field belongs in the virtual-path index"
3753        );
3754        assert_eq!(
3755            outcome.warnings,
3756            vec![BrokenLink {
3757                field: "page".to_string(),
3758                path: "/notes/a.md".to_string()
3759            }],
3760            "a path only a non-addressable field carries is not a link target"
3761        );
3762    }
3763
3764    #[test]
3765    fn two_records_may_share_the_path_of_a_non_addressable_field() {
3766        let (_dir, mut table) = open_pages_with_notes_table();
3767        let record = |id: &str| {
3768            json!({
3769                "id": id,
3770                "page": { "content": "", "virtualPath": format!("/docs/{id}.md") },
3771                "notes": { "content": "x", "virtualPath": "/notes/shared.md" },
3772            })
3773        };
3774
3775        table.put(&record("a"), false).unwrap();
3776        let second = table.put(&record("b"), false);
3777
3778        assert!(
3779            second.is_ok(),
3780            "an unindexed path enforces no uniqueness: {:?}",
3781            second.err()
3782        );
3783    }
3784
3785    #[test]
3786    fn a_non_addressable_field_needs_no_path_of_its_own_at_all() {
3787        let (_dir, mut table) = open_pages_with_notes_table();
3788
3789        table
3790            .put(
3791                &json!({ "id": "a", "page": { "content": "", "virtualPath": "/docs/a.md" }, "notes": { "content": "x" } }),
3792                false,
3793            )
3794            .unwrap();
3795
3796        let stored = table.get("a").unwrap().unwrap();
3797        assert_eq!(stored["notes"], json!({ "content": "x" }));
3798    }
3799
3800    #[test]
3801    fn a_table_opened_with_no_link_parsing_option_defaults_to_eager() {
3802        let (_dir, table) = open_pages_table();
3803        assert_eq!(table.link_parsing(), LinkParsing::Eager);
3804    }
3805
3806    #[test]
3807    fn eager_put_of_a_broken_link_still_succeeds_but_carries_a_warning() {
3808        let (_dir, mut table) = open_pages_table();
3809        let outcome = table
3810            .put_with_warnings(
3811                &json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
3812                false,
3813            )
3814            .unwrap();
3815
3816        assert!(table.get("a").unwrap().is_some());
3817        assert_eq!(
3818            outcome.warnings,
3819            vec![BrokenLink {
3820                field: "page".to_string(),
3821                path: "/docs/missing.md".to_string()
3822            }]
3823        );
3824    }
3825
3826    #[test]
3827    fn eager_put_of_a_link_to_an_existing_page_carries_no_warnings() {
3828        let (_dir, mut table) = open_pages_table();
3829        table
3830            .put_with_warnings(
3831                &json!({ "id": "a", "page": { "content": "", "virtualPath": "/docs/a.md" } }),
3832                false,
3833            )
3834            .unwrap();
3835
3836        let outcome = table
3837            .put_with_warnings(
3838                &json!({ "id": "b", "page": { "content": "[a](/docs/a.md)", "virtualPath": "/docs/b.md" } }),
3839                false,
3840            )
3841            .unwrap();
3842
3843        assert!(outcome.warnings.is_empty());
3844    }
3845
3846    #[test]
3847    fn eager_put_of_a_link_to_a_cover_images_virtual_path_in_the_same_record_carries_no_warnings() {
3848        let (_dir, mut table) = open_pages_with_cover_table();
3849        let outcome = table
3850            .put_with_warnings(
3851                &json!({
3852                    "id": "a",
3853                    "page": { "content": "[cover](/images/a.png)", "virtualPath": "/docs/a.md" },
3854                    "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" },
3855                }),
3856                false,
3857            )
3858            .unwrap();
3859
3860        assert!(outcome.warnings.is_empty());
3861    }
3862
3863    #[test]
3864    fn eager_put_of_a_link_to_a_cover_images_virtual_path_in_another_record_carries_no_warnings() {
3865        let (_dir, mut table) = open_pages_with_cover_table();
3866        table
3867            .put_with_warnings(
3868                &json!({ "id": "a", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
3869                false,
3870            )
3871            .unwrap();
3872
3873        let outcome = table
3874            .put_with_warnings(
3875                &json!({ "id": "b", "page": { "content": "[cover](/images/a.png)", "virtualPath": "/docs/b.md" } }),
3876                false,
3877            )
3878            .unwrap();
3879
3880        assert!(outcome.warnings.is_empty());
3881    }
3882
3883    #[test]
3884    fn eager_put_of_a_link_to_a_path_no_field_holds_still_carries_a_warning() {
3885        let (_dir, mut table) = open_pages_with_cover_table();
3886        table
3887            .put_with_warnings(
3888                &json!({ "id": "a", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
3889                false,
3890            )
3891            .unwrap();
3892
3893        let outcome = table
3894            .put_with_warnings(
3895                &json!({ "id": "b", "page": { "content": "[nope](/images/missing.png)", "virtualPath": "/docs/b.md" } }),
3896                false,
3897            )
3898            .unwrap();
3899
3900        assert_eq!(
3901            outcome.warnings,
3902            vec![BrokenLink {
3903                field: "page".to_string(),
3904                path: "/images/missing.png".to_string()
3905            }]
3906        );
3907    }
3908
3909    #[test]
3910    fn lazy_link_parsing_reports_no_automatic_warning_but_broken_links_of_finds_it_on_request() {
3911        let (_dir, mut table) = open_pages_table();
3912        table.set_link_parsing(LinkParsing::Lazy);
3913
3914        let outcome = table
3915            .put_with_warnings(
3916                &json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
3917                false,
3918            )
3919            .unwrap();
3920        assert!(outcome.warnings.is_empty());
3921
3922        let broken = table.broken_links_of("a").unwrap();
3923        assert_eq!(
3924            broken,
3925            vec![BrokenLink {
3926                field: "page".to_string(),
3927                path: "/docs/missing.md".to_string()
3928            }]
3929        );
3930    }
3931
3932    #[test]
3933    fn asking_after_the_write_gives_an_eager_table_the_same_warnings_put_with_warnings_carried() {
3934        let (_dir, mut table) = open_pages_table();
3935        let outcome = table
3936            .put_with_warnings(
3937                &json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
3938                false,
3939            )
3940            .unwrap();
3941
3942        assert_eq!(table.eager_link_warnings_of("a").unwrap(), outcome.warnings);
3943        assert_eq!(
3944            table.eager_link_warnings_of("a").unwrap(),
3945            vec![BrokenLink {
3946                field: "page".to_string(),
3947                path: "/docs/missing.md".to_string()
3948            }]
3949        );
3950    }
3951
3952    #[test]
3953    fn asking_after_the_write_stays_silent_on_a_lazy_table_that_broken_links_of_would_answer_for() {
3954        let (_dir, mut table) = open_pages_table();
3955        table.set_link_parsing(LinkParsing::Lazy);
3956        table
3957            .put_with_warnings(
3958                &json!({ "id": "a", "page": { "content": "[missing](/docs/missing.md)", "virtualPath": "/docs/a.md" } }),
3959                false,
3960            )
3961            .unwrap();
3962
3963        assert!(table.eager_link_warnings_of("a").unwrap().is_empty());
3964        assert_eq!(table.broken_links_of("a").unwrap().len(), 1);
3965    }
3966
3967    #[test]
3968    fn plain_put_and_patch_still_return_a_bare_record_alongside_the_warning_variants() {
3969        let (_dir, mut table) = open_pages_table();
3970        let record = table
3971            .put(
3972                &json!({ "id": "a", "page": { "content": "no links here", "virtualPath": "/docs/a.md" } }),
3973                false,
3974            )
3975            .unwrap();
3976        assert_eq!(record.get("id"), Some(&json!("a")));
3977
3978        let patched = table
3979            .patch(
3980                "a",
3981                &json!({ "page": { "content": "[missing](/docs/x.md)", "virtualPath": "/docs/a.md" } }),
3982                false,
3983            )
3984            .unwrap();
3985        assert_eq!(patched.get("id"), Some(&json!("a")));
3986    }
3987
3988    #[test]
3989    fn compact_drops_dead_versions_but_leaves_the_table_fully_usable() {
3990        let (_dir, mut table) = open_table();
3991        table
3992            .put(
3993                &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
3994                false,
3995            )
3996            .unwrap();
3997        table
3998            .put(
3999                &json!({ "id": "u2", "name": "Piotr", "age": 24, "email": "u2@x.com" }),
4000                false,
4001            )
4002            .unwrap();
4003        table
4004            .put(
4005                &json!({ "id": "u3", "name": "Kasia", "age": 40, "email": "u3@x.com" }),
4006                false,
4007            )
4008            .unwrap();
4009        table.patch("u2", &json!({ "age": 25 }), false).unwrap();
4010        table.delete("u3", false).unwrap();
4011
4012        let result = table.compact().unwrap();
4013        assert_eq!(result.records, 2);
4014        assert!(result.reclaimed_bytes > 0);
4015
4016        let u1 = table.get("u1").unwrap().unwrap();
4017        assert_eq!(u1.get("name"), Some(&json!("Ania")));
4018        let u2 = table.get("u2").unwrap().unwrap();
4019        assert_eq!(u2.get("age"), Some(&json!(25)));
4020        assert_eq!(table.get("u3").unwrap(), None);
4021
4022        table
4023            .put(
4024                &json!({ "id": "u4", "name": "Zosia", "age": 22, "email": "u4@x.com" }),
4025                false,
4026            )
4027            .unwrap();
4028        assert_eq!(table.get("u4").unwrap().unwrap().get("name"), Some(&json!("Zosia")));
4029        table.snapshot().unwrap();
4030        assert!(!table.needs_snapshot());
4031    }
4032
4033    mod half_written_tail {
4034        use std::io::Write as _;
4035
4036        use super::*;
4037
4038        /// The bytes a writer killed mid-append leaves behind: a line with no closing newline.
4039        const TORN: &[u8] = br#"{"_k":"u3","_op":"put","_seq":9,"_ts":1,"name":"Half"#;
4040
4041        fn seeded(path: &Path) -> u64 {
4042            let mut table = Table::open(users_schema(), path).unwrap();
4043            table
4044                .put(
4045                    &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
4046                    false,
4047                )
4048                .unwrap();
4049            table
4050                .put(
4051                    &json!({ "id": "u2", "name": "Piotr", "age": 40, "email": "u2@x.com" }),
4052                    false,
4053                )
4054                .unwrap();
4055            table.flush().unwrap();
4056            fs::metadata(path).unwrap().len()
4057        }
4058
4059        fn tear(path: &Path) {
4060            let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap();
4061            file.write_all(TORN).unwrap();
4062        }
4063
4064        fn tracked_size(table: &Table) -> u64 {
4065            table.logs.log_of(UNPARTITIONED).unwrap().size()
4066        }
4067
4068        #[test]
4069        fn a_writable_open_cuts_the_torn_tail_off_the_log_so_the_next_append_stays_readable() {
4070            let dir = tempdir().unwrap();
4071            let path = dir.path().join("users.jsonl");
4072            let healthy = seeded(&path);
4073            tear(&path);
4074            assert!(fs::metadata(&path).unwrap().len() > healthy);
4075
4076            let mut table = Table::open(users_schema(), &path).unwrap();
4077            assert_eq!(tracked_size(&table), healthy);
4078            assert_eq!(fs::metadata(&path).unwrap().len(), healthy);
4079
4080            table
4081                .put(
4082                    &json!({ "id": "u3", "name": "Zosia", "age": 22, "email": "u3@x.com" }),
4083                    false,
4084                )
4085                .unwrap();
4086            table.flush().unwrap();
4087
4088            // NOTE: no snapshot on this path, so this reopen is the full rescan the tear used to break.
4089            let reopened = Table::open(users_schema(), &path).unwrap();
4090            let mut keys: Vec<&str> = reopened.keys().map(String::as_str).collect();
4091            keys.sort_unstable();
4092            assert_eq!(keys, ["u1", "u2", "u3"]);
4093            assert_eq!(reopened.get("u3").unwrap().unwrap().get("name"), Some(&json!("Zosia")));
4094
4095            let raw = fs::read_to_string(&path).unwrap();
4096            let lines: Vec<&str> = raw.trim().lines().collect();
4097            assert_eq!(lines.len(), 3);
4098            assert!(lines.iter().all(|line| serde_json::from_str::<Value>(line).is_ok()));
4099        }
4100
4101        #[test]
4102        fn a_readonly_open_moves_its_own_end_marker_and_leaves_the_torn_file_alone() {
4103            let dir = tempdir().unwrap();
4104            let path = dir.path().join("users.jsonl");
4105            let healthy = seeded(&path);
4106            tear(&path);
4107            let torn_size = fs::metadata(&path).unwrap().len();
4108
4109            let (table, outcome) = Table::open_with_snapshot_and_declared_indexes(
4110                users_schema(),
4111                &path,
4112                dir.path().join("users.idx"),
4113                "",
4114                0,
4115                true,
4116                &[],
4117            )
4118            .unwrap();
4119
4120            assert_eq!(outcome, RestoreOutcome::Rebuilt);
4121            assert_eq!(tracked_size(&table), healthy);
4122            assert_eq!(fs::metadata(&path).unwrap().len(), torn_size);
4123            let mut keys: Vec<&str> = table.keys().map(String::as_str).collect();
4124            keys.sort_unstable();
4125            assert_eq!(keys, ["u1", "u2"]);
4126        }
4127    }
4128
4129    mod cypher {
4130        use super::*;
4131        use crate::crypto::{CipherOptions, MissingKeyPolicy};
4132        use std::fs;
4133
4134        fn key(byte: u8) -> Vec<u8> {
4135            vec![byte; 32]
4136        }
4137
4138        fn secrets_schema() -> TableSchema {
4139            parse_schema(&json!({
4140                "schemaVersion": 1,
4141                "table": "secrets",
4142                "primaryKey": "id",
4143                "fields": {
4144                    "id": { "type": "string" },
4145                    "label": { "type": "string", "index": "exact" },
4146                    "token": { "type": "string", "cypher": true },
4147                    "pin": { "type": "int", "cypher": true },
4148                },
4149            }))
4150            .unwrap()
4151        }
4152
4153        fn cipher_with(keys: &[(&str, u8)], on_missing_key: Option<MissingKeyPolicy>) -> FieldCipher {
4154            FieldCipher::new(CipherOptions {
4155                keys: keys.iter().map(|(id, byte)| (id.to_string(), key(*byte))).collect(),
4156                key_id: None,
4157                on_missing_key,
4158            })
4159            .unwrap()
4160        }
4161
4162        fn open_secrets(dir: &std::path::Path, cipher: Option<FieldCipher>) -> Table {
4163            let mut table = Table::open(secrets_schema(), dir.join("secrets.jsonl")).unwrap();
4164            table.set_cipher(cipher);
4165            table
4166        }
4167
4168        #[test]
4169        fn never_writes_the_plaintext_into_the_log() {
4170            let dir = tempdir().unwrap();
4171            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4172            table
4173                .put(
4174                    &json!({ "id": "s1", "label": "deploy", "token": "hunter2-super-secret", "pin": 4321 }),
4175                    false,
4176                )
4177                .unwrap();
4178
4179            let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
4180            assert!(!log.contains("hunter2-super-secret"));
4181            assert!(!log.contains("4321"));
4182            assert!(log.contains("$cypher$v1$k1$"));
4183            // NOTE: the non-encrypted field is the control — it proves the log really holds this record.
4184            assert!(log.contains("deploy"));
4185        }
4186
4187        #[test]
4188        fn hands_the_plaintext_back_on_read_with_its_type_intact() {
4189            let dir = tempdir().unwrap();
4190            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4191            table
4192                .put(&json!({ "id": "s1", "token": "hunter2", "pin": 4321 }), false)
4193                .unwrap();
4194
4195            let found = table.get("s1").unwrap().unwrap();
4196            assert_eq!(found.get("token"), Some(&json!("hunter2")));
4197            assert_eq!(found.get("pin"), Some(&json!(4321)));
4198        }
4199
4200        #[test]
4201        fn a_staged_value_reads_back_plain_and_still_lands_sealed() {
4202            let dir = tempdir().unwrap();
4203            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4204            let path = dir.path().join("secrets.jsonl");
4205            table
4206                .stage_put(&json!({ "id": "s1", "label": "deploy", "token": "top-secret" }), false)
4207                .unwrap();
4208
4209            assert_eq!(
4210                table.get("s1").unwrap().unwrap().get("token"),
4211                Some(&json!("top-secret"))
4212            );
4213            assert!(
4214                fs::read_to_string(&path).unwrap_or_default().is_empty(),
4215                "nothing is on disk yet"
4216            );
4217
4218            table.flush().unwrap();
4219
4220            let written = fs::read_to_string(&path).unwrap();
4221            assert!(!written.contains("top-secret"), "the plaintext never reached the log");
4222            assert!(written.contains("$cypher$v1$k1$"));
4223            assert_eq!(
4224                table.get("s1").unwrap().unwrap().get("token"),
4225                Some(&json!("top-secret"))
4226            );
4227        }
4228
4229        #[test]
4230        fn two_equal_values_get_two_different_envelopes() {
4231            let dir = tempdir().unwrap();
4232            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4233            table.put(&json!({ "id": "s1", "token": "same" }), false).unwrap();
4234            table.put(&json!({ "id": "s2", "token": "same" }), false).unwrap();
4235
4236            let lines: Vec<String> = fs::read_to_string(dir.path().join("secrets.jsonl"))
4237                .unwrap()
4238                .trim()
4239                .lines()
4240                .map(str::to_string)
4241                .collect();
4242            let envelopes: Vec<String> = lines
4243                .iter()
4244                .map(|line| {
4245                    serde_json::from_str::<Value>(line)
4246                        .unwrap()
4247                        .get("token")
4248                        .unwrap()
4249                        .as_str()
4250                        .unwrap()
4251                        .to_string()
4252                })
4253                .collect();
4254            assert_ne!(envelopes[0], envelopes[1]);
4255        }
4256
4257        #[test]
4258        fn leaves_null_alone() {
4259            let dir = tempdir().unwrap();
4260            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4261            table.put(&json!({ "id": "s1", "token": Value::Null }), false).unwrap();
4262            let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
4263            assert!(log.contains("\"token\":null"));
4264        }
4265
4266        #[test]
4267        fn survives_a_patch_of_an_encrypted_field() {
4268            let dir = tempdir().unwrap();
4269            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4270            table
4271                .put(
4272                    &json!({ "id": "s1", "label": "deploy", "token": "first", "pin": 1 }),
4273                    false,
4274                )
4275                .unwrap();
4276            table.patch("s1", &json!({ "token": "second" }), false).unwrap();
4277
4278            assert_eq!(table.get("s1").unwrap().unwrap().get("token"), Some(&json!("second")));
4279            assert_eq!(table.get("s1").unwrap().unwrap().get("pin"), Some(&json!(1)));
4280            let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
4281            assert!(!log.contains("\"second\""));
4282        }
4283
4284        #[test]
4285        fn survives_compaction_without_a_key() {
4286            let dir = tempdir().unwrap();
4287            {
4288                let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4289                table.put(&json!({ "id": "s1", "token": "keep-me" }), false).unwrap();
4290                table
4291                    .put(&json!({ "id": "s1", "token": "keep-me-instead" }), false)
4292                    .unwrap();
4293            }
4294
4295            {
4296                let mut blind = open_secrets(dir.path(), None);
4297                blind.compact().unwrap();
4298            }
4299
4300            let reopened = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4301            assert_eq!(
4302                reopened.get("s1").unwrap().unwrap().get("token"),
4303                Some(&json!("keep-me-instead"))
4304            );
4305        }
4306
4307        #[test]
4308        fn matches_a_predicate_on_an_encrypted_field_by_scanning() {
4309            let dir = tempdir().unwrap();
4310            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4311            table.put(&json!({ "id": "s1", "token": "alpha" }), false).unwrap();
4312            table.put(&json!({ "id": "s2", "token": "beta" }), false).unwrap();
4313
4314            let spec = QueryBuilder::new()
4315                .r#where("token", ComparisonOp::Eq, json!("beta"))
4316                .spec();
4317            let found = table.query(&spec).unwrap();
4318            assert_eq!(found.keys, vec!["s2".to_string()]);
4319        }
4320
4321        #[test]
4322        fn refuses_to_write_an_encrypted_field_with_no_key() {
4323            let dir = tempdir().unwrap();
4324            let mut blind = open_secrets(dir.path(), None);
4325            let error = blind.put(&json!({ "id": "s9", "token": "nope" }), false).unwrap_err();
4326            assert!(matches!(error, TableError::Cipher(_)));
4327        }
4328
4329        #[test]
4330        fn refuses_to_read_without_the_key_that_wrote_the_value() {
4331            let dir = tempdir().unwrap();
4332            {
4333                let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4334                table.put(&json!({ "id": "s1", "token": "secret" }), false).unwrap();
4335            }
4336
4337            let wrong = open_secrets(dir.path(), Some(cipher_with(&[("other", 2)], None)));
4338            let error = wrong.get("s1").unwrap_err();
4339            // The whole typed refusal reaches the caller, key id included — the read path used to
4340            // flatten it into an io::Error whose text was all that was left of it.
4341            let TableError::Cipher(cipher) = &error else {
4342                panic!("a failed decrypt is a cipher refusal, not an io failure: {error:?}");
4343            };
4344            assert_eq!(cipher.table, "secrets");
4345            assert_eq!(cipher.field, "token");
4346            assert_eq!(cipher.key_id.as_deref(), Some("k1"));
4347            assert!(error.to_string().contains("was not given"));
4348        }
4349
4350        #[test]
4351        fn refuses_to_read_with_no_key_at_all() {
4352            let dir = tempdir().unwrap();
4353            {
4354                let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4355                table.put(&json!({ "id": "s1", "token": "secret" }), false).unwrap();
4356            }
4357
4358            let blind = open_secrets(dir.path(), None);
4359            let error = blind.get("s1").unwrap_err();
4360            assert!(error.to_string().contains("cannot be read without a key"));
4361        }
4362
4363        #[test]
4364        fn omits_the_field_when_the_policy_says_so() {
4365            let dir = tempdir().unwrap();
4366            {
4367                let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4368                table
4369                    .put(&json!({ "id": "s1", "label": "deploy", "token": "secret" }), false)
4370                    .unwrap();
4371            }
4372
4373            let partial = open_secrets(
4374                dir.path(),
4375                Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Omit))),
4376            );
4377            let found = partial.get("s1").unwrap().unwrap();
4378            assert_eq!(found.get("label"), Some(&json!("deploy")));
4379            assert_eq!(found.get("token"), None);
4380        }
4381
4382        #[test]
4383        fn hands_back_the_envelope_when_the_policy_says_raw() {
4384            let dir = tempdir().unwrap();
4385            {
4386                let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4387                table.put(&json!({ "id": "s1", "token": "secret" }), false).unwrap();
4388            }
4389
4390            let raw = open_secrets(
4391                dir.path(),
4392                Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Raw))),
4393            );
4394            let found = raw.get("s1").unwrap().unwrap();
4395            assert!(found.get("token").unwrap().as_str().unwrap().contains("$cypher$v1$k1$"));
4396        }
4397
4398        const MASQUERADE: &str = "$cypher$v1$k1$not$real";
4399
4400        fn token_in(path: &std::path::Path, key: &str) -> String {
4401            fs::read_to_string(path)
4402                .unwrap()
4403                .lines()
4404                .filter_map(|line| serde_json::from_str::<Value>(line).ok())
4405                .rfind(|entry| entry["_k"] == json!(key))
4406                .unwrap()["token"]
4407                .as_str()
4408                .unwrap()
4409                .to_string()
4410        }
4411
4412        #[test]
4413        fn a_value_that_only_looks_like_an_envelope_is_sealed_like_any_other() {
4414            let dir = tempdir().unwrap();
4415            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4416
4417            table
4418                .put(&json!({ "id": "s1", "label": "deploy", "token": MASQUERADE }), false)
4419                .unwrap();
4420
4421            let path = dir.path().join("secrets.jsonl");
4422            let stored = token_in(&path, "s1");
4423            assert_ne!(stored, MASQUERADE, "the value reached the log in the clear");
4424            assert!(!fs::read_to_string(&path).unwrap().contains("$not$real"));
4425            assert_eq!(
4426                table.get("s1").unwrap().unwrap().get("token"),
4427                Some(&json!(MASQUERADE)),
4428                "and it still reads back as what was written"
4429            );
4430        }
4431
4432        #[test]
4433        fn a_copy_between_databases_carries_the_envelope_byte_for_byte() {
4434            let source = tempdir().unwrap();
4435            {
4436                let mut table = open_secrets(source.path(), Some(cipher_with(&[("k1", 1)], None)));
4437                table
4438                    .put(&json!({ "id": "s1", "label": "deploy", "token": "hunter2" }), false)
4439                    .unwrap();
4440            }
4441            let envelope = {
4442                let raw = open_secrets(
4443                    source.path(),
4444                    Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Raw))),
4445                );
4446                raw.get("s1").unwrap().unwrap()["token"].as_str().unwrap().to_string()
4447            };
4448
4449            // The restore side holds no key at all — it moves ciphertext it cannot read.
4450            let target = tempdir().unwrap();
4451            {
4452                let mut table = open_secrets(target.path(), None);
4453                table
4454                    .put_sealed(
4455                        &json!({ "id": "s1", "label": "deploy", "token": envelope }),
4456                        &["token".to_string()],
4457                        false,
4458                    )
4459                    .unwrap();
4460            }
4461
4462            assert_eq!(
4463                token_in(&target.path().join("secrets.jsonl"), "s1"),
4464                envelope,
4465                "the copy re-sealed a value it was told was already sealed"
4466            );
4467            let restored = open_secrets(target.path(), Some(cipher_with(&[("k1", 1)], None)));
4468            assert_eq!(
4469                restored.get("s1").unwrap().unwrap().get("token"),
4470                Some(&json!("hunter2"))
4471            );
4472        }
4473
4474        #[test]
4475        fn a_carried_field_is_refused_when_it_is_not_an_envelope() {
4476            let dir = tempdir().unwrap();
4477            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4478
4479            let error = table
4480                .put_sealed(
4481                    &json!({ "id": "s1", "token": MASQUERADE }),
4482                    &["token".to_string()],
4483                    false,
4484                )
4485                .unwrap_err();
4486
4487            assert!(error.to_string().contains("is not a cypher envelope"), "{error}");
4488            assert!(table.is_empty(), "the refused write still landed");
4489        }
4490
4491        #[test]
4492        fn a_carried_field_is_refused_when_the_envelope_belongs_to_another_record() {
4493            let dir = tempdir().unwrap();
4494            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4495            table.put(&json!({ "id": "s2", "token": "yours" }), false).unwrap();
4496            let stolen = {
4497                let raw = open_secrets(
4498                    dir.path(),
4499                    Some(cipher_with(&[("other", 2)], Some(MissingKeyPolicy::Raw))),
4500                );
4501                raw.get("s2").unwrap().unwrap()["token"].as_str().unwrap().to_string()
4502            };
4503
4504            let error = table
4505                .put_sealed(&json!({ "id": "s1", "token": stolen }), &["token".to_string()], false)
4506                .unwrap_err();
4507
4508            assert!(
4509                error.to_string().contains("does not open for this record and field"),
4510                "{error}"
4511            );
4512        }
4513
4514        #[test]
4515        fn a_field_that_is_not_declared_cypher_cannot_be_carried() {
4516            let dir = tempdir().unwrap();
4517            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4518
4519            let error = table
4520                .put_sealed(&json!({ "id": "s1", "label": "deploy" }), &["label".to_string()], false)
4521                .unwrap_err();
4522
4523            assert!(error.to_string().contains("not declared \"cypher\""), "{error}");
4524        }
4525
4526        #[test]
4527        fn a_carried_field_does_not_spare_another_write_in_the_same_window() {
4528            let source = tempdir().unwrap();
4529            {
4530                let mut table = open_secrets(source.path(), Some(cipher_with(&[("k1", 1)], None)));
4531                table.put(&json!({ "id": "s1", "token": "hunter2" }), false).unwrap();
4532            }
4533            let envelope = token_in(&source.path().join("secrets.jsonl"), "s1");
4534
4535            let target = tempdir().unwrap();
4536            let mut table = open_secrets(target.path(), Some(cipher_with(&[("k1", 1)], None)));
4537            table
4538                .stage_put(&json!({ "id": "s2", "token": MASQUERADE }), false)
4539                .unwrap();
4540            table
4541                .put_sealed(
4542                    &json!({ "id": "s1", "token": envelope.clone() }),
4543                    &["token".to_string()],
4544                    false,
4545                )
4546                .unwrap();
4547
4548            let path = target.path().join("secrets.jsonl");
4549            assert_eq!(token_in(&path, "s1"), envelope);
4550            assert_ne!(
4551                token_in(&path, "s2"),
4552                MASQUERADE,
4553                "the import's claim spared a record it was never about"
4554            );
4555        }
4556
4557        #[test]
4558        fn refuses_a_value_moved_to_another_record() {
4559            let dir = tempdir().unwrap();
4560            {
4561                let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4562                table.put(&json!({ "id": "s1", "token": "mine" }), false).unwrap();
4563                table.put(&json!({ "id": "s2", "token": "yours" }), false).unwrap();
4564            }
4565
4566            let path = dir.path().join("secrets.jsonl");
4567            let lines: Vec<String> = fs::read_to_string(&path)
4568                .unwrap()
4569                .trim()
4570                .lines()
4571                .map(str::to_string)
4572                .collect();
4573            let mut first: serde_json::Map<String, Value> = serde_json::from_str(&lines[0]).unwrap();
4574            let mut second: serde_json::Map<String, Value> = serde_json::from_str(&lines[1]).unwrap();
4575            let stolen = first.remove("token").unwrap();
4576            second.insert("token".to_string(), stolen);
4577            fs::write(
4578                &path,
4579                format!(
4580                    "{}\n{}\n",
4581                    serde_json::to_string(&first).unwrap(),
4582                    serde_json::to_string(&second).unwrap()
4583                ),
4584            )
4585            .unwrap();
4586
4587            let reopened = open_secrets(dir.path(), Some(cipher_with(&[("k1", 1)], None)));
4588            let error = reopened.get("s2").unwrap_err();
4589            assert!(error.to_string().contains("authentication tag"));
4590        }
4591
4592        /// Several keys on the ring, `write_id` the one new values are sealed with — the shape a
4593        /// rotation is run under, which `cipher_with` cannot express (it leaves `keyId` unset).
4594        fn cipher_writing(
4595            keys: &[(&str, u8)],
4596            write_id: &str,
4597            on_missing_key: Option<MissingKeyPolicy>,
4598        ) -> FieldCipher {
4599            FieldCipher::new(CipherOptions {
4600                keys: keys.iter().map(|(id, byte)| ((*id).to_string(), key(*byte))).collect(),
4601                key_id: Some(write_id.to_string()),
4602                on_missing_key,
4603            })
4604            .unwrap()
4605        }
4606
4607        /// One record sealed under `k1`, then a handle whose write key is `k2` and which writes a
4608        /// second record — the "one old, one new" table every rotation test starts from.
4609        fn seeded_across_two_keys(dir: &std::path::Path) -> Table {
4610            let mut old = open_secrets(dir, Some(cipher_with(&[("k1", 1)], None)));
4611            old.put(
4612                &json!({ "id": "s1", "label": "old", "token": "first", "pin": 1111 }),
4613                false,
4614            )
4615            .unwrap();
4616            drop(old);
4617
4618            let mut table = open_secrets(dir, Some(cipher_writing(&[("k1", 1), ("k2", 2)], "k2", None)));
4619            table
4620                .put(
4621                    &json!({ "id": "s2", "label": "new", "token": "second", "pin": 2222 }),
4622                    false,
4623                )
4624                .unwrap();
4625            table
4626        }
4627
4628        fn envelope_key_ids_in(path: &std::path::Path) -> Vec<String> {
4629            fs::read_to_string(path)
4630                .unwrap()
4631                .lines()
4632                .filter(|line| !line.is_empty())
4633                .map(|line| {
4634                    let record: serde_json::Map<String, Value> = serde_json::from_str(line).unwrap();
4635                    crate::crypto::envelope_key_id(&record["token"]).unwrap().to_string()
4636                })
4637                .collect()
4638        }
4639
4640        #[test]
4641        fn rotation_re_encrypts_only_the_record_still_on_the_retired_key() {
4642            let dir = tempdir().unwrap();
4643            let mut table = seeded_across_two_keys(dir.path());
4644
4645            let result = table.rotate_cypher_keys().unwrap();
4646
4647            assert_eq!(
4648                (result.rotated, result.already_current, result.skipped),
4649                (1, 1, 0),
4650                "only \"s1\" was on k1"
4651            );
4652            let written = envelope_key_ids_in(&dir.path().join("secrets.jsonl"));
4653            assert_eq!(
4654                written,
4655                vec!["k1".to_string(), "k2".to_string(), "k2".to_string()],
4656                "the k1 line stays in the append-only log; the rotation appends the k2 version after it"
4657            );
4658        }
4659
4660        #[test]
4661        fn a_second_rotation_reports_nothing_left_to_do() {
4662            let dir = tempdir().unwrap();
4663            let mut table = seeded_across_two_keys(dir.path());
4664            table.rotate_cypher_keys().unwrap();
4665
4666            let again = table.rotate_cypher_keys().unwrap();
4667
4668            assert_eq!(
4669                (again.rotated, again.skipped),
4670                (0, 0),
4671                "a rotation nothing needs must rewrite nothing"
4672            );
4673            assert_eq!(again.already_current, 2);
4674        }
4675
4676        #[test]
4677        fn every_value_still_reads_back_as_itself_after_a_rotation() {
4678            let dir = tempdir().unwrap();
4679            let mut table = seeded_across_two_keys(dir.path());
4680
4681            table.rotate_cypher_keys().unwrap();
4682
4683            for (key, token, pin, label) in [("s1", "first", 1111, "old"), ("s2", "second", 2222, "new")] {
4684                let found = table.get(key).unwrap().unwrap();
4685                assert_eq!(found.get("token"), Some(&json!(token)), "{key}");
4686                assert_eq!(found.get("pin"), Some(&json!(pin)), "{key}");
4687                assert_eq!(found.get("label"), Some(&json!(label)), "{key}");
4688            }
4689        }
4690
4691        #[test]
4692        fn a_rotated_record_reads_back_with_the_new_key_alone() {
4693            let dir = tempdir().unwrap();
4694            let mut table = seeded_across_two_keys(dir.path());
4695            table.rotate_cypher_keys().unwrap();
4696            drop(table);
4697
4698            let without_the_old_key = open_secrets(dir.path(), Some(cipher_with(&[("k2", 2)], None)));
4699
4700            assert_eq!(
4701                without_the_old_key.get("s1").unwrap().unwrap().get("token"),
4702                Some(&json!("first")),
4703                "the whole point of rotating: k1 can now be retired"
4704            );
4705        }
4706
4707        #[test]
4708        fn a_table_with_no_cypher_field_answers_zero_without_reading_anything() {
4709            let dir = tempdir().unwrap();
4710            let mut table = Table::open(users_schema(), dir.path().join("users.jsonl")).unwrap();
4711            table
4712                .put(
4713                    &json!({ "id": "u1", "name": "Ania", "age": 30, "email": "u1@x.com" }),
4714                    false,
4715                )
4716                .unwrap();
4717
4718            let result = table.rotate_cypher_keys().unwrap();
4719
4720            assert_eq!(result, RotationResult::default());
4721        }
4722
4723        #[test]
4724        fn a_missing_key_refuses_the_run_under_the_default_policy() {
4725            let dir = tempdir().unwrap();
4726            drop(seeded_across_two_keys(dir.path()));
4727            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k2", 2)], None)));
4728
4729            let error = table.rotate_cypher_keys().unwrap_err();
4730
4731            assert!(
4732                error.to_string().contains("s1"),
4733                "the refusal has to name the record: {error}"
4734            );
4735            assert!(error.to_string().contains("k1"), "and the key it wanted: {error}");
4736        }
4737
4738        #[test]
4739        fn a_missing_key_is_skipped_and_counted_when_the_policy_says_so() {
4740            let dir = tempdir().unwrap();
4741            drop(seeded_across_two_keys(dir.path()));
4742            let mut table = open_secrets(dir.path(), Some(cipher_with(&[("k2", 2)], Some(MissingKeyPolicy::Raw))));
4743
4744            let result = table.rotate_cypher_keys().unwrap();
4745
4746            assert_eq!(
4747                (result.rotated, result.already_current, result.skipped),
4748                (0, 1, 1),
4749                "the unreadable record is left alone rather than re-sealed from its own envelope"
4750            );
4751            let written = envelope_key_ids_in(&dir.path().join("secrets.jsonl"));
4752            assert_eq!(
4753                written,
4754                vec!["k1".to_string(), "k2".to_string()],
4755                "nothing was appended"
4756            );
4757        }
4758
4759        #[test]
4760        fn rotation_reaches_every_partition_of_a_partitioned_table() {
4761            let schema = parse_schema(&json!({
4762                "schemaVersion": 1,
4763                "table": "secrets",
4764                "primaryKey": "id",
4765                "partitionBy": "tenant",
4766                "fields": {
4767                    "id": { "type": "string" },
4768                    "tenant": { "type": "string", "required": true },
4769                    "token": { "type": "string", "cypher": true },
4770                },
4771            }))
4772            .unwrap();
4773            let dir = tempdir().unwrap();
4774            let path = dir.path().join("secrets.jsonl");
4775            let open = |cipher| {
4776                let mut table = Table::open(schema.clone(), &path).unwrap();
4777                table.set_cipher(Some(cipher));
4778                table
4779            };
4780            let mut old = open(cipher_with(&[("k1", 1)], None));
4781            old.put(&json!({ "id": "s1", "tenant": "acme", "token": "a" }), false)
4782                .unwrap();
4783            old.put(&json!({ "id": "s2", "tenant": "globex", "token": "b" }), false)
4784                .unwrap();
4785            drop(old);
4786            let mut table = open(cipher_writing(&[("k1", 1), ("k2", 2)], "k2", None));
4787
4788            let result = table.rotate_cypher_keys().unwrap();
4789
4790            assert_eq!((result.rotated, result.already_current), (2, 0));
4791            for (key, tenant, token) in [("s1", "acme", "a"), ("s2", "globex", "b")] {
4792                let found = table.get(key).unwrap().unwrap();
4793                assert_eq!(found.get("token"), Some(&json!(token)));
4794                assert_eq!(
4795                    found.get("tenant"),
4796                    Some(&json!(tenant)),
4797                    "the record must not change partition"
4798                );
4799            }
4800            for tenant in ["acme", "globex"] {
4801                let log = fs::read_to_string(dir.path().join("secrets").join(format!("{tenant}.jsonl"))).unwrap();
4802                assert!(log.contains("$cypher$v1$k2$"), "{tenant} was rotated in its own file");
4803            }
4804        }
4805
4806        #[test]
4807        fn rotation_is_refused_on_a_readonly_handle() {
4808            let dir = tempdir().unwrap();
4809            drop(seeded_across_two_keys(dir.path()));
4810            let mut table = Table::open_with_snapshot_and_declared_indexes(
4811                secrets_schema(),
4812                dir.path().join("secrets.jsonl"),
4813                dir.path().join("secrets.idx"),
4814                "",
4815                0,
4816                true,
4817                &[],
4818            )
4819            .unwrap()
4820            .0;
4821            table.set_cipher(Some(cipher_writing(&[("k1", 1), ("k2", 2)], "k2", None)));
4822
4823            let error = table.rotate_cypher_keys().unwrap_err();
4824
4825            assert!(error.to_string().contains("readonly"), "{error}");
4826        }
4827    }
4828
4829    mod cypher_txn {
4830        use super::*;
4831        use crate::crypto::CipherOptions;
4832        use std::collections::HashMap;
4833        use std::fs;
4834
4835        fn key(byte: u8) -> Vec<u8> {
4836            vec![byte; 32]
4837        }
4838
4839        fn secrets_schema() -> TableSchema {
4840            parse_schema(&json!({
4841                "schemaVersion": 1,
4842                "table": "secrets",
4843                "primaryKey": "id",
4844                "fields": {
4845                    "id": { "type": "string" },
4846                    "token": { "type": "string", "cypher": true },
4847                },
4848            }))
4849            .unwrap()
4850        }
4851
4852        #[test]
4853        fn prepare_txn_ops_seals_the_line_but_keeps_the_prepared_record_plain() {
4854            let dir = tempdir().unwrap();
4855            let cipher = FieldCipher::new(CipherOptions {
4856                keys: HashMap::from([("k1".to_string(), key(1))]),
4857                key_id: None,
4858                on_missing_key: None,
4859            })
4860            .unwrap();
4861            let mut table = Table::open(secrets_schema(), dir.path().join("secrets.jsonl")).unwrap();
4862            table.set_cipher(Some(cipher));
4863
4864            let prepared = table
4865                .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "s1", "token": "top-secret" }))])
4866                .unwrap();
4867            assert_eq!(prepared.len(), 1);
4868            assert!(prepared[0].line.contains("$cypher$v1$k1$"));
4869            assert!(!prepared[0].line.contains("top-secret"));
4870            assert_eq!(
4871                prepared[0].record.as_ref().unwrap().get("token"),
4872                Some(&json!("top-secret"))
4873            );
4874
4875            table.apply_txn_entries(prepared).unwrap();
4876            let log = fs::read_to_string(dir.path().join("secrets.jsonl")).unwrap();
4877            assert!(!log.contains("top-secret"));
4878            assert_eq!(
4879                table.get("s1").unwrap().unwrap().get("token"),
4880                Some(&json!("top-secret"))
4881            );
4882        }
4883    }
4884
4885    /// Ported one for one from `packages/hayson/test/partition.test.ts` — the TS engine's contract
4886    /// for `partitionBy` is the specification this port is measured against.
4887    mod partitions {
4888        use std::fs;
4889
4890        use super::*;
4891        use crate::query::predicates::ComparisonOp;
4892        use crate::registry::fulltext_index::SearchMode;
4893
4894        fn notes_schema(partitioned: bool) -> TableSchema {
4895            let mut spec = json!({
4896                "schemaVersion": 1,
4897                "table": "notes",
4898                "primaryKey": "id",
4899                "fields": {
4900                    "id": { "type": "string" },
4901                    "kind": { "type": "string", "required": true, "index": "exact" },
4902                    "title": { "type": "string", "required": true, "fulltext": true },
4903                    "body": { "type": "string", "fulltext": true },
4904                    "slug": { "type": "string", "unique": true },
4905                },
4906            });
4907            if partitioned {
4908                spec.as_object_mut()
4909                    .unwrap()
4910                    .insert("partitionBy".into(), json!("kind"));
4911            }
4912            parse_schema(&spec).unwrap()
4913        }
4914
4915        fn seed() -> Vec<Value> {
4916            vec![
4917                json!({ "id": "n1", "kind": "note", "title": "Backend developer", "body": "silnik zapytań", "slug": "n1" }),
4918                json!({ "id": "n2", "kind": "note", "title": "Frontend notes", "body": "react", "slug": "n2" }),
4919                json!({ "id": "a1", "kind": "adr", "title": "Backend decision", "body": "silnik składowania", "slug": "a1" }),
4920                json!({ "id": "r1", "kind": "rule", "title": "Naming rule", "body": "kebab case", "slug": "r1" }),
4921            ]
4922        }
4923
4924        fn log_path(dir: &std::path::Path) -> std::path::PathBuf {
4925            dir.join("notes.jsonl")
4926        }
4927
4928        fn open_notes(dir: &std::path::Path) -> Table {
4929            Table::open(notes_schema(true), log_path(dir)).unwrap()
4930        }
4931
4932        fn partition_lines(dir: &std::path::Path, part: &str) -> Vec<Value> {
4933            fs::read_to_string(dir.join("notes").join(format!("{part}.jsonl")))
4934                .unwrap()
4935                .trim()
4936                .lines()
4937                .map(|line| serde_json::from_str(line).unwrap())
4938                .collect()
4939        }
4940
4941        #[test]
4942        fn keeps_one_log_file_per_partition_value() {
4943            let dir = tempdir().unwrap();
4944            let mut notes = open_notes(dir.path());
4945            for note in seed() {
4946                notes.put(&note, false).unwrap();
4947            }
4948
4949            assert!(!log_path(dir.path()).exists());
4950            assert_eq!(partition_lines(dir.path(), "note").len(), 2);
4951            assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
4952            assert_eq!(partition_lines(dir.path(), "rule").len(), 1);
4953            for line in partition_lines(dir.path(), "adr") {
4954                assert_eq!(line.get("kind"), Some(&json!("adr")));
4955            }
4956        }
4957
4958        /// A live key cannot change partition (`queue::assert_partition_stable` refuses it), so the
4959        /// only way its history spans two files is delete-then-write-elsewhere — and then the
4960        /// tombstone sits in the old file while the new version sits in the new one. Sorting by
4961        /// `seq` is what puts the two halves back together.
4962        #[test]
4963        fn history_of_a_key_that_moved_partitions_reads_as_one_list_across_both_files() {
4964            let dir = tempdir().unwrap();
4965            let mut notes = open_notes(dir.path());
4966            notes
4967                .put(
4968                    &json!({ "id": "n1", "kind": "note", "title": "First", "slug": "n1" }),
4969                    false,
4970                )
4971                .unwrap();
4972            notes.patch("n1", &json!({ "title": "Second" }), false).unwrap();
4973            let moved = notes.put(
4974                &json!({ "id": "n1", "kind": "adr", "title": "Third", "slug": "n1" }),
4975                false,
4976            );
4977            assert!(moved.is_err(), "a live key may not change partition");
4978            notes.delete("n1", false).unwrap();
4979            notes
4980                .put(
4981                    &json!({ "id": "n1", "kind": "adr", "title": "Third", "slug": "n1" }),
4982                    false,
4983                )
4984                .unwrap();
4985
4986            let history = notes.history("n1").unwrap();
4987
4988            assert_eq!(
4989                history.scanned,
4990                vec![
4991                    dir.path().join("notes").join("adr.jsonl"),
4992                    dir.path().join("notes").join("note.jsonl"),
4993                ]
4994            );
4995            let shape: Vec<(u64, LogOp, &str, Option<&Value>)> = history
4996                .entries
4997                .iter()
4998                .map(|entry| {
4999                    (
5000                        entry.seq,
5001                        entry.op,
5002                        entry.partition.as_str(),
5003                        entry.document.as_ref().and_then(|d| d.get("title")),
5004                    )
5005                })
5006                .collect();
5007            assert_eq!(
5008                shape,
5009                vec![
5010                    (1, LogOp::Put, "note", Some(&json!("First"))),
5011                    (2, LogOp::Put, "note", Some(&json!("Second"))),
5012                    (3, LogOp::Del, "note", None),
5013                    (4, LogOp::Put, "adr", Some(&json!("Third"))),
5014                ]
5015            );
5016            assert!(notes.history("n2").unwrap().entries.is_empty());
5017        }
5018
5019        /// A table whose `partitionBy` was declared but whose split has not run yet still keeps
5020        /// everything in the flat log, and a readonly handle reads it there — `open_logs` says so,
5021        /// and history has to follow it rather than look in a directory that does not exist.
5022        #[test]
5023        fn history_of_a_declared_but_unsplit_table_reads_the_flat_log() {
5024            let dir = tempdir().unwrap();
5025            {
5026                let mut flat = Table::open(notes_schema(false), log_path(dir.path())).unwrap();
5027                flat.put(
5028                    &json!({ "id": "n1", "kind": "note", "title": "First", "slug": "n1" }),
5029                    false,
5030                )
5031                .unwrap();
5032                flat.patch("n1", &json!({ "title": "Second" }), false).unwrap();
5033            }
5034
5035            let (notes, _) = Table::open_with_snapshot_and_declared_indexes(
5036                notes_schema(true),
5037                log_path(dir.path()),
5038                dir.path().join("notes.idx"),
5039                "hash",
5040                0,
5041                true,
5042                &[],
5043            )
5044            .unwrap();
5045
5046            let history = notes.history("n1").unwrap();
5047
5048            assert_eq!(history.scanned, vec![log_path(dir.path())]);
5049            assert_eq!(
5050                history
5051                    .entries
5052                    .iter()
5053                    .map(|entry| (entry.seq, entry.partition.clone()))
5054                    .collect::<Vec<_>>(),
5055                vec![(1, UNPARTITIONED.to_string()), (2, UNPARTITIONED.to_string())]
5056            );
5057        }
5058
5059        #[test]
5060        fn reads_every_partition_back_after_a_restart() {
5061            let dir = tempdir().unwrap();
5062            {
5063                let mut notes = open_notes(dir.path());
5064                for note in seed() {
5065                    notes.put(&note, false).unwrap();
5066                }
5067            }
5068
5069            let notes = open_notes(dir.path());
5070            assert_eq!(notes.registry.records(), 4);
5071            assert_eq!(
5072                notes.get("a1").unwrap().unwrap().get("title"),
5073                Some(&json!("Backend decision"))
5074            );
5075            assert_eq!(notes.get("r1").unwrap().unwrap().get("kind"), Some(&json!("rule")));
5076
5077            let spec = QueryBuilder::new()
5078                .r#where("kind", ComparisonOp::Eq, json!("note"))
5079                .spec();
5080            let mut found = notes.query(&spec).unwrap().keys;
5081            found.sort();
5082            assert_eq!(found, vec!["n1".to_string(), "n2".to_string()]);
5083        }
5084
5085        #[test]
5086        fn pairs_the_index_snapshot_with_every_partition_it_covers() {
5087            let dir = tempdir().unwrap();
5088            let snapshot_path = dir.path().join("notes.idx");
5089            let (mut notes, _) =
5090                Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5091            for note in seed() {
5092                notes.put(&note, false).unwrap();
5093            }
5094            notes.snapshot().unwrap();
5095
5096            let snapshot = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
5097            let parts = snapshot.header.parts.unwrap();
5098            assert_eq!(
5099                parts.iter().map(|part| part.part.clone()).collect::<Vec<_>>(),
5100                vec!["adr".to_string(), "note".to_string(), "rule".to_string()]
5101            );
5102            for part in &parts {
5103                let on_disk = fs::metadata(dir.path().join("notes").join(format!("{}.jsonl", part.part))).unwrap();
5104                assert_eq!(part.watermark, on_disk.len());
5105                assert!(!part.log_hash.is_empty());
5106            }
5107            assert_eq!(snapshot.header.log_hash, None);
5108        }
5109
5110        #[test]
5111        fn compaction_repairs_every_partition_watermark_in_the_snapshot() {
5112            let dir = tempdir().unwrap();
5113            let snapshot_path = dir.path().join("notes.idx");
5114            let (mut notes, _) =
5115                Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5116            for note in seed() {
5117                notes.put(&note, false).unwrap();
5118            }
5119            notes
5120                .put(&json!({ "id": "n1", "kind": "note", "title": "Rewritten" }), false)
5121                .unwrap();
5122            notes.delete("r1", false).unwrap();
5123            notes.snapshot().unwrap();
5124
5125            notes.compact().unwrap();
5126
5127            let snapshot = crate::checkpoint::read_snapshot(&snapshot_path).unwrap().unwrap();
5128            for part in snapshot.header.parts.unwrap() {
5129                let on_disk = fs::metadata(dir.path().join("notes").join(format!("{}.jsonl", part.part))).unwrap();
5130                assert_eq!(part.watermark, on_disk.len(), "partition \"{}\" watermark", part.part);
5131                assert_eq!(part.dead_bytes, 0, "partition \"{}\" dead bytes", part.part);
5132            }
5133            assert_eq!(snapshot.header.watermark, notes.logs.total_size());
5134
5135            let (reopened, outcome) =
5136                Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5137            assert_eq!(outcome, RestoreOutcome::Restored);
5138            assert_eq!(reopened.registry.records(), 3);
5139            assert_eq!(
5140                reopened.get("n1").unwrap().unwrap().get("title"),
5141                Some(&json!("Rewritten"))
5142            );
5143            assert_eq!(reopened.get("r1").unwrap(), None);
5144        }
5145
5146        #[test]
5147        fn restores_the_registry_from_a_snapshot_that_covers_every_partition() {
5148            let dir = tempdir().unwrap();
5149            let snapshot_path = dir.path().join("notes.idx");
5150            {
5151                let (mut notes, _) =
5152                    Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0)
5153                        .unwrap();
5154                for note in seed() {
5155                    notes.put(&note, false).unwrap();
5156                }
5157                notes.snapshot().unwrap();
5158            }
5159
5160            let (notes, outcome) =
5161                Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5162            assert_eq!(outcome, RestoreOutcome::Restored);
5163            assert_eq!(notes.registry.records(), 4);
5164            assert_eq!(
5165                notes.get("a1").unwrap().unwrap().get("title"),
5166                Some(&json!("Backend decision"))
5167            );
5168        }
5169
5170        #[test]
5171        fn rebuilds_when_one_partition_grew_after_the_snapshot_was_taken() {
5172            let dir = tempdir().unwrap();
5173            let snapshot_path = dir.path().join("notes.idx");
5174            {
5175                let (mut notes, _) =
5176                    Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0)
5177                        .unwrap();
5178                for note in seed() {
5179                    notes.put(&note, false).unwrap();
5180                }
5181                notes.snapshot().unwrap();
5182                notes
5183                    .put(
5184                        &json!({ "id": "n3", "kind": "note", "title": "after the snapshot" }),
5185                        false,
5186                    )
5187                    .unwrap();
5188            }
5189
5190            let (notes, outcome) =
5191                Table::open_with_snapshot(notes_schema(true), log_path(dir.path()), &snapshot_path, "hash", 0).unwrap();
5192            assert_eq!(outcome, RestoreOutcome::Rebuilt);
5193            assert_eq!(notes.registry.records(), 5);
5194            assert_eq!(
5195                notes.get("n3").unwrap().unwrap().get("title"),
5196                Some(&json!("after the snapshot"))
5197            );
5198        }
5199
5200        #[test]
5201        fn holds_uniqueness_across_partitions_not_just_inside_one() {
5202            let dir = tempdir().unwrap();
5203            let mut notes = open_notes(dir.path());
5204            notes
5205                .put(
5206                    &json!({ "id": "n1", "kind": "note", "title": "first", "slug": "shared" }),
5207                    false,
5208                )
5209                .unwrap();
5210
5211            let error = notes
5212                .put(
5213                    &json!({ "id": "a1", "kind": "adr", "title": "second", "slug": "shared" }),
5214                    false,
5215                )
5216                .unwrap_err();
5217            assert!(matches!(error, TableError::Unique(_)));
5218        }
5219
5220        #[test]
5221        fn refuses_a_partition_value_that_cannot_name_a_file() {
5222            let dir = tempdir().unwrap();
5223            let mut notes = open_notes(dir.path());
5224            for kind in ["../escape", "Upper", "has space", ""] {
5225                let error = notes
5226                    .put(&json!({ "id": "x", "kind": kind, "title": "nope" }), false)
5227                    .unwrap_err();
5228                assert!(
5229                    matches!(error, TableError::Partition(_)),
5230                    "kind {kind:?} must be refused"
5231                );
5232            }
5233            assert!(!dir.path().join("notes").exists());
5234        }
5235
5236        #[test]
5237        fn refuses_to_move_a_record_between_partitions() {
5238            let dir = tempdir().unwrap();
5239            let mut notes = open_notes(dir.path());
5240            notes
5241                .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5242                .unwrap();
5243
5244            let put_error = notes
5245                .put(&json!({ "id": "n1", "kind": "adr", "title": "moved" }), false)
5246                .unwrap_err();
5247            assert!(matches!(put_error, TableError::Partition(_)));
5248            let patch_error = notes.patch("n1", &json!({ "kind": "adr" }), false).unwrap_err();
5249            assert!(matches!(patch_error, TableError::Partition(_)));
5250
5251            assert_eq!(notes.get("n1").unwrap().unwrap().get("kind"), Some(&json!("note")));
5252            assert!(!dir.path().join("notes/adr.jsonl").exists());
5253        }
5254
5255        #[test]
5256        fn refuses_a_staged_write_that_would_move_a_key_the_window_already_placed() {
5257            let dir = tempdir().unwrap();
5258            let mut table = open_notes(dir.path());
5259            table
5260                .stage_put(
5261                    &json!({ "id": "n1", "kind": "note", "title": "Backend", "slug": "n1" }),
5262                    false,
5263                )
5264                .unwrap();
5265
5266            let refused = table
5267                .stage_put(
5268                    &json!({ "id": "n1", "kind": "adr", "title": "Backend", "slug": "n1" }),
5269                    true,
5270                )
5271                .unwrap_err();
5272
5273            assert!(matches!(refused, TableError::Partition(_)));
5274            assert!(
5275                refused.to_string().contains("already lives in partition \"note\""),
5276                "the partition came from the window, which the registry knows nothing about: {refused}"
5277            );
5278        }
5279
5280        #[test]
5281        fn rewrites_a_record_inside_its_own_partition() {
5282            let dir = tempdir().unwrap();
5283            let mut notes = open_notes(dir.path());
5284            notes
5285                .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5286                .unwrap();
5287            notes.patch("n1", &json!({ "title": "second" }), false).unwrap();
5288
5289            assert_eq!(notes.get("n1").unwrap().unwrap().get("title"), Some(&json!("second")));
5290            assert_eq!(partition_lines(dir.path(), "note").len(), 2);
5291        }
5292
5293        #[test]
5294        fn puts_a_tombstone_in_the_partition_the_record_lived_in() {
5295            let dir = tempdir().unwrap();
5296            let mut notes = open_notes(dir.path());
5297            notes
5298                .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5299                .unwrap();
5300            notes
5301                .put(&json!({ "id": "a1", "kind": "adr", "title": "kept" }), false)
5302                .unwrap();
5303
5304            assert!(notes.delete("n1", false).unwrap());
5305            assert!(!notes.delete("nope", false).unwrap());
5306
5307            let ops: Vec<Value> = partition_lines(dir.path(), "note")
5308                .iter()
5309                .map(|line| line.get("_op").unwrap().clone())
5310                .collect();
5311            assert_eq!(ops, vec![json!("put"), json!("del")]);
5312            assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
5313        }
5314
5315        #[test]
5316        fn lands_a_unit_of_work_that_spans_two_partitions_of_one_table() {
5317            let dir = tempdir().unwrap();
5318            let mut notes = open_notes(dir.path());
5319            let prepared = notes
5320                .prepare_txn_ops(&[
5321                    TxnOp::Put(json!({ "id": "n1", "kind": "note", "title": "from unit" })),
5322                    TxnOp::Put(json!({ "id": "a1", "kind": "adr", "title": "from unit too" })),
5323                ])
5324                .unwrap();
5325            assert_eq!(prepared.len(), 2);
5326            notes.apply_txn_entries(prepared).unwrap();
5327
5328            assert_eq!(
5329                notes.get("n1").unwrap().unwrap().get("title"),
5330                Some(&json!("from unit"))
5331            );
5332            assert_eq!(
5333                notes.get("a1").unwrap().unwrap().get("title"),
5334                Some(&json!("from unit too"))
5335            );
5336            assert_eq!(partition_lines(dir.path(), "note").len(), 1);
5337            assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
5338        }
5339
5340        #[test]
5341        fn refuses_a_unit_of_work_that_would_move_a_record_between_partitions() {
5342            let dir = tempdir().unwrap();
5343            let mut notes = open_notes(dir.path());
5344            notes
5345                .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5346                .unwrap();
5347
5348            let outcome = notes.prepare_txn_ops(&[TxnOp::Put(json!({ "id": "n1", "kind": "adr", "title": "moved" }))]);
5349            assert!(matches!(outcome, Err(TableError::Partition(_))));
5350            assert_eq!(notes.get("n1").unwrap().unwrap().get("kind"), Some(&json!("note")));
5351        }
5352
5353        #[test]
5354        fn compacts_one_partition_and_leaves_the_others_byte_for_byte() {
5355            let dir = tempdir().unwrap();
5356            let mut notes = open_notes(dir.path());
5357            notes
5358                .put(&json!({ "id": "n1", "kind": "note", "title": "first" }), false)
5359                .unwrap();
5360            notes
5361                .put(&json!({ "id": "a1", "kind": "adr", "title": "cold" }), false)
5362                .unwrap();
5363            for round in 0..8 {
5364                notes
5365                    .patch("n1", &json!({ "title": format!("round {round}") }), false)
5366                    .unwrap();
5367            }
5368
5369            let cold_before = fs::read_to_string(dir.path().join("notes/adr.jsonl")).unwrap();
5370            assert_eq!(partition_lines(dir.path(), "note").len(), 9);
5371
5372            let result = notes.compact().unwrap();
5373            assert_eq!(result.records, 2);
5374            assert_eq!(partition_lines(dir.path(), "note").len(), 1);
5375            assert_eq!(
5376                fs::read_to_string(dir.path().join("notes/adr.jsonl")).unwrap(),
5377                cold_before
5378            );
5379            assert_eq!(notes.get("n1").unwrap().unwrap().get("title"), Some(&json!("round 7")));
5380            assert_eq!(notes.get("a1").unwrap().unwrap().get("title"), Some(&json!("cold")));
5381        }
5382
5383        fn seeded_flat(dir: &std::path::Path) -> Table {
5384            let mut notes = Table::open(notes_schema(false), log_path(dir)).unwrap();
5385            for note in seed() {
5386                notes.put(&note, false).unwrap();
5387            }
5388            notes.delete("n2", false).unwrap();
5389            notes
5390        }
5391
5392        #[test]
5393        fn moves_the_live_records_into_one_file_per_partition_when_the_schema_starts_partitioning() {
5394            let dir = tempdir().unwrap();
5395            drop(seeded_flat(dir.path()));
5396
5397            let notes = open_notes(dir.path());
5398            assert_eq!(notes.registry.records(), 3);
5399            assert_eq!(notes.get("n2").unwrap(), None);
5400            assert_eq!(
5401                notes.get("a1").unwrap().unwrap().get("title"),
5402                Some(&json!("Backend decision"))
5403            );
5404
5405            assert!(!log_path(dir.path()).exists());
5406            assert_eq!(partition_lines(dir.path(), "note").len(), 1);
5407            assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
5408            assert_eq!(partition_lines(dir.path(), "rule").len(), 1);
5409        }
5410
5411        fn open_notes_readonly(dir: &std::path::Path) -> (Table, RestoreOutcome) {
5412            Table::open_with_snapshot_and_declared_indexes(
5413                notes_schema(true),
5414                log_path(dir),
5415                dir.join("notes.idx"),
5416                "hash",
5417                0,
5418                true,
5419                &[],
5420            )
5421            .unwrap()
5422        }
5423
5424        #[test]
5425        fn a_readonly_open_reads_the_flat_log_the_move_has_not_touched_yet() {
5426            let dir = tempdir().unwrap();
5427            drop(seeded_flat(dir.path()));
5428            let before = fs::read_to_string(log_path(dir.path())).unwrap();
5429
5430            let (notes, _) = open_notes_readonly(dir.path());
5431
5432            assert_eq!(notes.registry.records(), 3);
5433            assert_eq!(notes.get("n2").unwrap(), None);
5434            assert_eq!(
5435                notes.get("a1").unwrap().unwrap().get("title"),
5436                Some(&json!("Backend decision"))
5437            );
5438            assert_eq!(
5439                fs::read_to_string(log_path(dir.path())).unwrap(),
5440                before,
5441                "a readonly open must leave the flat log byte for byte as it found it"
5442            );
5443            assert!(
5444                !dir.path().join("notes").exists(),
5445                "a readonly open must not create the partition directory"
5446            );
5447        }
5448
5449        #[test]
5450        fn a_readonly_open_of_the_unmoved_flat_log_writes_no_index_snapshot() {
5451            let dir = tempdir().unwrap();
5452            drop(seeded_flat(dir.path()));
5453
5454            let (notes, outcome) = open_notes_readonly(dir.path());
5455
5456            assert_eq!(outcome, RestoreOutcome::Rebuilt);
5457            assert_eq!(notes.registry.records(), 3);
5458            assert!(
5459                !dir.path().join("notes.idx").exists(),
5460                "a readonly open must not write the index snapshot"
5461            );
5462        }
5463
5464        #[test]
5465        fn a_writer_still_finishes_the_move_a_readonly_open_left_undone() {
5466            let dir = tempdir().unwrap();
5467            drop(seeded_flat(dir.path()));
5468            drop(open_notes_readonly(dir.path()));
5469
5470            let notes = open_notes(dir.path());
5471
5472            assert_eq!(notes.registry.records(), 3);
5473            assert_eq!(
5474                notes.get("a1").unwrap().unwrap().get("title"),
5475                Some(&json!("Backend decision"))
5476            );
5477            assert!(!log_path(dir.path()).exists());
5478            assert_eq!(partition_lines(dir.path(), "note").len(), 1);
5479            assert_eq!(partition_lines(dir.path(), "adr").len(), 1);
5480            assert_eq!(partition_lines(dir.path(), "rule").len(), 1);
5481        }
5482
5483        #[test]
5484        fn a_readonly_open_reads_the_partitions_once_the_move_has_run() {
5485            let dir = tempdir().unwrap();
5486            drop(seeded_flat(dir.path()));
5487            drop(open_notes(dir.path()));
5488
5489            let (notes, _) = open_notes_readonly(dir.path());
5490
5491            assert!(notes.logs.is_partitioned());
5492            assert_eq!(notes.registry.records(), 3);
5493            assert_eq!(notes.get("r1").unwrap().unwrap().get("kind"), Some(&json!("rule")));
5494        }
5495
5496        #[test]
5497        fn keeps_full_text_results_identical_to_the_unpartitioned_table() {
5498            let dir = tempdir().unwrap();
5499            let flat_hits = {
5500                let flat = seeded_flat(dir.path());
5501                let spec = QueryBuilder::new().search("silnik", SearchMode::All).spec();
5502                let mut hits = flat.query(&spec).unwrap().keys;
5503                hits.sort();
5504                hits
5505            };
5506
5507            let notes = open_notes(dir.path());
5508            let spec = QueryBuilder::new().search("silnik", SearchMode::All).spec();
5509            let mut partitioned_hits = notes.query(&spec).unwrap().keys;
5510            partitioned_hits.sort();
5511
5512            assert!(!flat_hits.is_empty());
5513            assert_eq!(partitioned_hits, flat_hits);
5514        }
5515
5516        #[test]
5517        fn writes_and_reads_further_records_after_the_move() {
5518            let dir = tempdir().unwrap();
5519            drop(seeded_flat(dir.path()));
5520
5521            let mut notes = open_notes(dir.path());
5522            notes
5523                .put(
5524                    &json!({ "id": "n3", "kind": "note", "title": "written after the move" }),
5525                    false,
5526                )
5527                .unwrap();
5528            assert!(notes.delete("a1", false).unwrap());
5529
5530            assert_eq!(
5531                notes.get("n3").unwrap().unwrap().get("title"),
5532                Some(&json!("written after the move"))
5533            );
5534            assert_eq!(notes.get("a1").unwrap(), None);
5535            assert_eq!(partition_lines(dir.path(), "note").len(), 2);
5536            assert_eq!(partition_lines(dir.path(), "adr").len(), 2);
5537        }
5538
5539        #[test]
5540        fn an_unpartitioned_table_keeps_writing_the_single_flat_log() {
5541            let dir = tempdir().unwrap();
5542            let mut notes = Table::open(notes_schema(false), log_path(dir.path())).unwrap();
5543            for note in seed() {
5544                notes.put(&note, false).unwrap();
5545            }
5546
5547            assert!(log_path(dir.path()).is_file());
5548            assert!(!dir.path().join("notes").exists());
5549            assert_eq!(
5550                fs::read_to_string(log_path(dir.path())).unwrap().trim().lines().count(),
5551                4
5552            );
5553            assert_eq!(notes.get("n1").unwrap().unwrap().get("slug"), Some(&json!("n1")));
5554        }
5555    }
5556
5557    /// The bounded LRU of parsed documents `Table::get` may answer from — see `table::cache`. Every
5558    /// test here is about what the cache may never do: outlive a write, a foreign write caught by
5559    /// `refresh`, a compaction, or its own size limit.
5560    mod read_cache {
5561        use std::io::Write as _;
5562
5563        use super::*;
5564
5565        /// Small enough that a record with a few-hundred-character field counts as large, and far
5566        /// above what the seeded records measure.
5567        const LARGE_BYTES: usize = 512;
5568
5569        fn policy(entries: usize, large_entries: usize) -> CachePolicy {
5570            CachePolicy::Enabled {
5571                entries,
5572                large_entries,
5573                large_bytes: LARGE_BYTES,
5574            }
5575        }
5576
5577        fn open_cached(cache: CachePolicy) -> (tempfile::TempDir, Table, PathBuf) {
5578            let (dir, mut table) = open_table();
5579            let path = dir.path().join("users.jsonl");
5580            table.set_cache(cache);
5581            (dir, table, path)
5582        }
5583
5584        fn seed(table: &mut Table, key: &str, name: &str) {
5585            table
5586                .put(&json!({ "id": key, "name": name, "age": 30 }), false)
5587                .unwrap();
5588        }
5589
5590        fn name_of(table: &Table, key: &str) -> Value {
5591            table.get(key).unwrap().unwrap().get("name").unwrap().clone()
5592        }
5593
5594        /// Overwrites every byte of the log with something no reader can parse, leaving the file
5595        /// length alone so the registry's offsets still point inside it: a `get` that answers after
5596        /// this never went back to the file, and one that reaches for it fails loudly.
5597        fn scribble(path: &Path) {
5598            let length = fs::metadata(path).unwrap().len() as usize;
5599            let mut file = fs::OpenOptions::new().write(true).open(path).unwrap();
5600            file.write_all(&vec![b'#'; length]).unwrap();
5601        }
5602
5603        #[test]
5604        fn a_second_get_of_a_cached_key_answers_without_touching_the_log_again() {
5605            let (_dir, mut table, path) = open_cached(policy(4, 1));
5606            seed(&mut table, "u1", "Ania");
5607            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5608
5609            scribble(&path);
5610
5611            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5612        }
5613
5614        #[test]
5615        fn without_a_cache_the_same_second_get_goes_back_to_the_log() {
5616            let (dir, mut table) = open_table();
5617            let path = dir.path().join("users.jsonl");
5618            seed(&mut table, "u1", "Ania");
5619            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5620
5621            scribble(&path);
5622
5623            assert!(
5624                matches!(table.get("u1"), Err(TableError::Io(_))),
5625                "an uncached read parses the log line every time"
5626            );
5627        }
5628
5629        #[test]
5630        fn a_table_nobody_configured_caches_nothing_and_reads_exactly_as_before() {
5631            let (_dir, mut table) = open_table();
5632            assert_eq!(table.cache_policy(), CachePolicy::Disabled);
5633
5634            seed(&mut table, "u1", "Ania");
5635            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5636            table.patch("u1", &json!({ "name": "Zosia" }), false).unwrap();
5637            assert_eq!(name_of(&table, "u1"), json!("Zosia"));
5638            assert!(table.delete("u1", false).unwrap());
5639            assert_eq!(table.get("u1").unwrap(), None);
5640            assert_eq!(table.cache.len(), (0, 0));
5641        }
5642
5643        #[test]
5644        fn a_put_over_a_cached_key_is_what_the_next_get_answers() {
5645            let (_dir, mut table, _path) = open_cached(policy(4, 1));
5646            seed(&mut table, "u1", "Ania");
5647            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5648
5649            seed(&mut table, "u1", "Zosia");
5650
5651            assert_eq!(name_of(&table, "u1"), json!("Zosia"));
5652        }
5653
5654        #[test]
5655        fn a_patch_over_a_cached_key_is_what_the_next_get_answers() {
5656            let (_dir, mut table, _path) = open_cached(policy(4, 1));
5657            seed(&mut table, "u1", "Ania");
5658            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5659
5660            table.patch("u1", &json!({ "age": 41 }), false).unwrap();
5661
5662            assert_eq!(table.get("u1").unwrap().unwrap().get("age"), Some(&json!(41)));
5663        }
5664
5665        #[test]
5666        fn a_delete_of_a_cached_key_leaves_nothing_for_the_next_get() {
5667            let (_dir, mut table, _path) = open_cached(policy(4, 1));
5668            seed(&mut table, "u1", "Ania");
5669            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5670
5671            assert!(table.delete("u1", false).unwrap());
5672
5673            assert_eq!(table.get("u1").unwrap(), None);
5674            assert_eq!(table.cache.len(), (0, 0));
5675        }
5676
5677        #[test]
5678        fn the_limit_evicts_the_least_recently_read_key_and_it_still_reads_off_the_log() {
5679            let (_dir, mut table, _path) = open_cached(policy(2, 1));
5680            seed(&mut table, "u1", "Ania");
5681            seed(&mut table, "u2", "Piotr");
5682            seed(&mut table, "u3", "Zosia");
5683
5684            assert_eq!(name_of(&table, "u2"), json!("Piotr"));
5685            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5686            // NOTE: this third read is the one that has to evict u2 — the least recently read of the two.
5687            assert_eq!(name_of(&table, "u3"), json!("Zosia"));
5688
5689            assert_eq!(table.cache.len(), (2, 0));
5690            assert_eq!(
5691                name_of(&table, "u2"),
5692                json!("Piotr"),
5693                "the evicted key falls back to the log"
5694            );
5695        }
5696
5697        #[test]
5698        fn an_evicted_key_reaches_for_the_log_while_the_two_kept_ones_do_not() {
5699            let (_dir, mut table, path) = open_cached(policy(2, 1));
5700            seed(&mut table, "u1", "Ania");
5701            seed(&mut table, "u2", "Piotr");
5702            seed(&mut table, "u3", "Zosia");
5703            table.get("u2").unwrap();
5704            table.get("u1").unwrap();
5705            table.get("u3").unwrap();
5706
5707            scribble(&path);
5708
5709            assert!(matches!(table.get("u2"), Err(TableError::Io(_))));
5710            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5711            assert_eq!(name_of(&table, "u3"), json!("Zosia"));
5712        }
5713
5714        #[test]
5715        fn large_documents_share_their_own_smaller_tier_and_leave_the_small_ones_alone() {
5716            let (_dir, mut table, path) = open_cached(policy(2, 1));
5717            let long = "x".repeat(LARGE_BYTES);
5718            seed(&mut table, "u1", "Ania");
5719            seed(&mut table, "u2", "Piotr");
5720            seed(&mut table, "d1", &long);
5721            seed(&mut table, "d2", &long);
5722
5723            table.get("u1").unwrap();
5724            table.get("u2").unwrap();
5725            table.get("d1").unwrap();
5726            table.get("d2").unwrap();
5727
5728            assert_eq!(
5729                table.cache.len(),
5730                (2, 1),
5731                "two large documents share one slot; the small ones keep both of theirs"
5732            );
5733
5734            scribble(&path);
5735
5736            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5737            assert_eq!(name_of(&table, "u2"), json!("Piotr"));
5738            assert_eq!(name_of(&table, "d2"), json!(long));
5739            assert!(
5740                matches!(table.get("d1"), Err(TableError::Io(_))),
5741                "the older large document was the one evicted"
5742            );
5743        }
5744
5745        #[test]
5746        fn a_refresh_that_catches_a_foreign_write_drops_the_document_it_cached() {
5747            let (_dir, mut writer, mut follower) = two_handles();
5748            follower.set_cache(policy(4, 1));
5749            writer
5750                .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
5751                .unwrap();
5752            follower.refresh().unwrap();
5753            assert_eq!(name_of(&follower, "u1"), json!("Ania"));
5754
5755            writer
5756                .put(&json!({ "id": "u1", "name": "Zosia", "age": 30 }), false)
5757                .unwrap();
5758
5759            assert_eq!(follower.refresh().unwrap(), RefreshOutcome::CaughtUp { entries: 1 });
5760            assert_eq!(name_of(&follower, "u1"), json!("Zosia"));
5761        }
5762
5763        #[test]
5764        fn a_following_read_of_a_cached_key_sees_the_foreign_write_it_caught_up_with() {
5765            let (_dir, mut writer, mut follower) = two_handles();
5766            follower.set_cache(policy(4, 1));
5767            follower.set_follow(Some(FollowPolicy {
5768                interval: Duration::ZERO,
5769            }));
5770            writer
5771                .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
5772                .unwrap();
5773            assert_eq!(
5774                follower.get_following("u1").unwrap().unwrap().get("name"),
5775                Some(&json!("Ania"))
5776            );
5777
5778            writer
5779                .put(&json!({ "id": "u1", "name": "Zosia", "age": 30 }), false)
5780                .unwrap();
5781
5782            assert_eq!(
5783                follower.get_following("u1").unwrap().unwrap().get("name"),
5784                Some(&json!("Zosia"))
5785            );
5786        }
5787
5788        #[test]
5789        fn a_reload_after_a_foreign_compaction_throws_the_whole_cache_away() {
5790            let (_dir, mut writer, mut follower) = two_handles();
5791            follower.set_cache(policy(4, 1));
5792            writer
5793                .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
5794                .unwrap();
5795            writer
5796                .put(&json!({ "id": "u2", "name": "Piotr", "age": 41 }), false)
5797                .unwrap();
5798            follower.refresh().unwrap();
5799            assert_eq!(name_of(&follower, "u1"), json!("Ania"));
5800            assert_eq!(name_of(&follower, "u2"), json!("Piotr"));
5801
5802            writer
5803                .put(&json!({ "id": "u1", "name": "Zosia", "age": 30 }), false)
5804                .unwrap();
5805            writer.compact().unwrap();
5806
5807            assert_eq!(follower.refresh().unwrap(), RefreshOutcome::Reloaded);
5808            assert_eq!(name_of(&follower, "u1"), json!("Zosia"));
5809            assert_eq!(name_of(&follower, "u2"), json!("Piotr"));
5810        }
5811
5812        #[test]
5813        fn compacting_this_handles_own_log_leaves_nothing_cached_behind() {
5814            let (_dir, mut table, _path) = open_cached(policy(4, 1));
5815            seed(&mut table, "u1", "Ania");
5816            seed(&mut table, "u2", "Piotr");
5817            seed(&mut table, "u1", "Zosia");
5818            table.get("u1").unwrap();
5819            table.get("u2").unwrap();
5820
5821            table.compact().unwrap();
5822
5823            assert_eq!(
5824                table.cache.len(),
5825                (0, 0),
5826                "every offset the cache was filled under moved"
5827            );
5828            assert_eq!(name_of(&table, "u1"), json!("Zosia"));
5829            assert_eq!(name_of(&table, "u2"), json!("Piotr"));
5830        }
5831
5832        #[test]
5833        fn a_unit_of_work_entry_applied_over_a_cached_key_invalidates_it() {
5834            let (_dir, mut table, _path) = open_cached(policy(4, 1));
5835            seed(&mut table, "u1", "Ania");
5836            assert_eq!(name_of(&table, "u1"), json!("Ania"));
5837
5838            let prepared = table
5839                .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Zosia", "age": 30 }))])
5840                .unwrap();
5841            table.apply_txn_entries(prepared).unwrap();
5842
5843            assert_eq!(name_of(&table, "u1"), json!("Zosia"));
5844        }
5845
5846        #[test]
5847        fn a_recovered_journal_line_over_a_cached_key_invalidates_it() {
5848            let (_dir, mut writer, mut follower) = two_handles();
5849            follower.set_cache(policy(4, 1));
5850            writer
5851                .put(&json!({ "id": "u1", "name": "Ania", "age": 30 }), false)
5852                .unwrap();
5853            follower.refresh().unwrap();
5854            assert_eq!(name_of(&follower, "u1"), json!("Ania"));
5855
5856            let prepared = writer
5857                .prepare_txn_ops(&[TxnOp::Put(json!({ "id": "u1", "name": "Zosia", "age": 30 }))])
5858                .unwrap();
5859            let lines: Vec<(u64, String)> = prepared
5860                .iter()
5861                .map(|entry| (entry.seq(), entry.line().to_string()))
5862                .collect();
5863            assert_eq!(follower.recover_txn_lines(&lines).unwrap(), 1);
5864
5865            assert_eq!(name_of(&follower, "u1"), json!("Zosia"));
5866        }
5867
5868        #[test]
5869        fn re_arming_the_cache_starts_it_cold() {
5870            let (_dir, mut table, path) = open_cached(policy(4, 1));
5871            seed(&mut table, "u1", "Ania");
5872            table.get("u1").unwrap();
5873
5874            table.set_cache(policy(4, 1));
5875            scribble(&path);
5876
5877            assert!(matches!(table.get("u1"), Err(TableError::Io(_))));
5878        }
5879    }
5880}