Skip to main content

hayson_engine/table/
handle.rs

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