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