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