Skip to main content

hayson_engine/table/
handle.rs

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