Skip to main content

hayson_engine/table/
handle.rs

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