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