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