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