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