noxu_db/secondary_database.rs
1//! Secondary database handle.
2//!
3//!
4//! A secondary database is an index over a primary database. Records are
5//! automatically maintained when the primary is written. Reads via a
6//! secondary return primary data; deletes via a secondary delete the
7//! corresponding primary record.
8//!
9//! The mapping of secondary keys to primary records is stored in an ordinary
10//! Database whose records have the form:
11//!
12//! key = secondary_key
13//! value = primary_key
14//!
15//! On every primary `put` the secondary is updated via `update_secondary`.
16//! On every primary `delete` the secondary entry is removed.
17//!
18//! # Atomicity with the primary write (Sprint 4½)
19//!
20//! [`SecondaryDatabase::update_secondary`] takes an explicit
21//! `Option<&Transaction>` parameter that is forwarded to every
22//! [`Database`] operation it performs against the inner secondary index.
23//! When the caller threads the *same* `txn` through
24//! [`Database::put`] / [`Database::delete`] **and**
25//! [`SecondaryDatabase::update_secondary`], the primary write and the
26//! secondary index update are atomic — committing or aborting the txn
27//! commits or rolls back **both** sides together. See
28//! `docs/src/transactions/secondary-with-txn.md` for the canonical
29//! pattern.
30//!
31//! Pre-Sprint-4½ (v1.4 / v1.5.0-rc1 / v1.5.0-rc2) `update_secondary` ran
32//! auto-committed regardless of any caller transaction, leaving a
33//! partial-atomicity gap (see audit Theme 2 / finding F5): an aborted
34//! primary `put` could leave the secondary entry behind on disk. The
35//! gap is closed for the manual-update pattern. Automatic
36//! `associate()`-style maintenance — where `Database::put` itself
37//! drives all attached secondaries inside the same txn — remains v1.6
38//! work.
39//!
40//! # v1.5 limitations
41//!
42//! See `docs/src/internal/v1.5-decisions-2026-05.md`.
43//!
44//! - **Decision 1B** — v1.5 secondaries are honestly **one-to-one**: a given
45//! secondary key may map to at most one primary key. Two distinct
46//! primaries that produce the same secondary key cause the second
47//! `update_secondary` (or `populate_if_empty`) to fail with a typed
48//! [`NoxuError::Unsupported`] (closes audit finding C4). Sorted-dup
49//! secondaries are planned for v1.6.
50//! - **Decision 2C** — foreign-key constraints are not enforced in v1.5.
51//! [`SecondaryDatabase::open`] rejects any [`SecondaryConfig`] whose
52//! foreign-key fields are set with [`NoxuError::Unsupported`] (closes
53//! audit findings C2, F1, F16). Full FK support is planned for v1.6.
54//! - **Automatic secondary maintenance** is not implemented in v1.5;
55//! callers must invoke `update_secondary` manually after each primary
56//! `put` / `delete` (planned for v1.6).
57
58use crate::cursor::Cursor;
59use crate::cursor_config::CursorConfig;
60use crate::database::Database;
61use crate::database_entry::DatabaseEntry;
62use crate::error::{NoxuError, Result};
63use crate::operation_status::OperationStatus;
64use crate::secondary_config::SecondaryConfig;
65use crate::secondary_cursor::SecondaryCursor;
66use crate::transaction::Transaction;
67use noxu_dbi::{CursorImpl, GetMode};
68use noxu_sync::Mutex;
69use std::cell::RefCell;
70use std::collections::HashSet;
71use std::sync::Arc;
72use std::sync::atomic::{AtomicBool, Ordering};
73
74thread_local! {
75 /// Cycle-detection frame for FK cascades and nullifications.
76 /// Contains every `(db_id, fk_value)` pair the current thread is
77 /// in the middle of cascading. See [`FkReferrer`].
78 static FK_CASCADE_GUARD: RefCell<HashSet<(u64, Vec<u8>)>> =
79 RefCell::new(HashSet::new());
80}
81
82/// Trait implemented by [`SecondaryHookState`] so a primary
83/// [`Database`] can keep the secondary registry as
84/// `Vec<Weak<dyn SecondaryHook + Send + Sync>>` without naming the
85/// concrete state struct (the struct holds a non-`Send` config field
86/// for some user-supplied callbacks; the trait only exposes the
87/// txn-driven update entry point and the secondary's name for
88/// diagnostics).
89///
90/// v1.6 (audit C3 — the `associate()`-style hook).
91pub(crate) trait SecondaryHook {
92 /// Updates this secondary index after a primary write. Called by
93 /// `Database::put` (`old_data` = `None`, `new_data = Some(…)`),
94 /// `Database::delete` (`old_data = Some(…)`, `new_data = None`),
95 /// or a primary update path (both `Some`). `txn` is the same
96 /// transaction that drove the primary write so the secondary update
97 /// participates atomically.
98 fn maintain(
99 &self,
100 txn: Option<&Transaction>,
101 pri_key: &DatabaseEntry,
102 old_data: Option<&DatabaseEntry>,
103 new_data: Option<&DatabaseEntry>,
104 ) -> Result<()>;
105
106 /// Returns the secondary's database name (used in diagnostics).
107 fn name(&self) -> String;
108}
109
110/// Trait implemented by [`SecondaryHookState`] for the FK referrer
111/// registry (v1.6 audit C2 / Decision 2C). When a record is deleted
112/// from a foreign-key target primary, the engine iterates every
113/// registered referrer and calls
114/// [`FkReferrer::on_foreign_key_deleted`] under the same caller-supplied
115/// txn. The implementation runs the configured
116/// [`ForeignKeyDeleteAction`] for every secondary key matching the
117/// deleted foreign key.
118pub(crate) trait FkReferrer {
119 /// Called when a foreign-DB primary record is about to be deleted.
120 /// `fk_value` is the primary key of the foreign DB record (which
121 /// may also be a secondary key in this child index).
122 ///
123 /// Returning `Err(NoxuError::ForeignConstraintViolation(…))` aborts
124 /// the foreign delete (Abort action). Returning `Ok(())` may have
125 /// already mutated the child primary records (Cascade / Nullify).
126 fn on_foreign_key_deleted(
127 &self,
128 txn: Option<&Transaction>,
129 fk_value: &DatabaseEntry,
130 ) -> Result<()>;
131
132 /// Returns the child secondary's database name (used in error messages).
133 fn name(&self) -> String;
134}
135
136/// Internal state of a [`SecondaryDatabase`].
137///
138/// Held behind an `Arc` so the primary database can keep a `Weak<_>`
139/// reference for automatic-maintenance fan-out without creating a
140/// cycle. Dropping the [`SecondaryDatabase`] handle drops the strong
141/// `Arc`; the next primary registration will purge the now-dangling
142/// `Weak`.
143pub(crate) struct SecondaryHookState {
144 /// The underlying secondary index storage (sec_key -> [pri_key…]).
145 pub(crate) inner: Database,
146 /// The primary database this index is associated with.
147 pub(crate) primary: Arc<Mutex<Database>>,
148 /// The secondary configuration (holds key creator callback, etc.).
149 pub(crate) config: SecondaryConfig,
150 /// Whether this secondary is fully populated (not in incremental mode).
151 pub(crate) is_fully_populated: AtomicBool,
152}
153
154impl SecondaryHookState {
155 /// Updates this secondary index after a primary insert / update /
156 /// delete. Mirrors the v1.5 [`SecondaryDatabase::update_secondary`]
157 /// behaviour but lives on the state so it can be invoked from the
158 /// [`SecondaryHook`] trait impl as well as the public
159 /// [`SecondaryDatabase`] facade.
160 pub(crate) fn update_secondary(
161 &self,
162 txn: Option<&Transaction>,
163 pri_key: &DatabaseEntry,
164 old_data: Option<&DatabaseEntry>,
165 new_data: Option<&DatabaseEntry>,
166 ) -> Result<()> {
167 let key_creator = &self.config.key_creator;
168 let multi_key_creator = &self.config.multi_key_creator;
169
170 if old_data.is_none() && new_data.is_none() {
171 return Ok(());
172 }
173
174 if let Some(creator) = key_creator {
175 let old_sec_key = old_data.and_then(|od| {
176 let mut sk = DatabaseEntry::new();
177 if creator.create_secondary_key(
178 &self.inner,
179 pri_key,
180 od,
181 &mut sk,
182 ) {
183 Some(sk)
184 } else {
185 None
186 }
187 });
188 let new_sec_key = new_data.and_then(|nd| {
189 let mut sk = DatabaseEntry::new();
190 if creator.create_secondary_key(
191 &self.inner,
192 pri_key,
193 nd,
194 &mut sk,
195 ) {
196 Some(sk)
197 } else {
198 None
199 }
200 });
201 let do_delete = old_sec_key.is_some()
202 && old_sec_key.as_ref() != new_sec_key.as_ref();
203 let do_insert = new_sec_key.is_some()
204 && new_sec_key.as_ref() != old_sec_key.as_ref();
205 if do_delete {
206 self.delete_sec_key(
207 txn,
208 old_sec_key.as_ref().unwrap(),
209 pri_key,
210 )?;
211 }
212 if do_insert {
213 self.insert_sec_key(
214 txn,
215 new_sec_key.as_ref().unwrap(),
216 pri_key,
217 )?;
218 }
219 } else if let Some(multi_creator) = multi_key_creator {
220 let empty = Vec::<DatabaseEntry>::new();
221 let old_keys: Vec<DatabaseEntry> = if let Some(od) = old_data {
222 let mut keys = Vec::new();
223 multi_creator.create_secondary_keys(
224 &self.inner,
225 pri_key,
226 od,
227 &mut keys,
228 );
229 keys
230 } else {
231 empty.clone()
232 };
233 let new_keys: Vec<DatabaseEntry> = if let Some(nd) = new_data {
234 let mut keys = Vec::new();
235 multi_creator.create_secondary_keys(
236 &self.inner,
237 pri_key,
238 nd,
239 &mut keys,
240 );
241 keys
242 } else {
243 empty
244 };
245 for old_key in &old_keys {
246 if !new_keys.contains(old_key) {
247 self.delete_sec_key(txn, old_key, pri_key)?;
248 }
249 }
250 for new_key in &new_keys {
251 if !old_keys.contains(new_key) {
252 self.insert_sec_key(txn, new_key, pri_key)?;
253 }
254 }
255 }
256
257 Ok(())
258 }
259
260 /// Inserts a (sec_key, pri_key) duplicate. See the
261 /// [`SecondaryDatabase`] impl for the full doc-comment; this is the
262 /// state-side implementation that the public method delegates to.
263 fn insert_sec_key(
264 &self,
265 txn: Option<&Transaction>,
266 sec_key: &DatabaseEntry,
267 pri_key: &DatabaseEntry,
268 ) -> Result<()> {
269 let mut cursor = self.make_inner_cursor(txn)?;
270 let status =
271 cursor
272 .put(sec_key, pri_key, crate::put::Put::NoDupData)
273 .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
274 match status {
275 OperationStatus::Success => Ok(()),
276 OperationStatus::KeyExists => Ok(()),
277 other => Err(NoxuError::OperationNotAllowed(format!(
278 "unexpected put status from secondary index insert: {other:?}"
279 ))),
280 }
281 }
282
283 /// Deletes the exact (sec_key, pri_key) duplicate.
284 fn delete_sec_key(
285 &self,
286 txn: Option<&Transaction>,
287 sec_key: &DatabaseEntry,
288 pri_key: &DatabaseEntry,
289 ) -> Result<()> {
290 let mut cursor = self.make_inner_cursor(txn)?;
291 let mut sec_key_mut = sec_key.clone();
292 let mut pri_key_mut = pri_key.clone();
293 let status = cursor
294 .get(
295 &mut sec_key_mut,
296 &mut pri_key_mut,
297 crate::get::Get::SearchBoth,
298 None,
299 )
300 .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
301 if status == OperationStatus::Success {
302 cursor
303 .delete()
304 .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
305 }
306 Ok(())
307 }
308
309 fn make_inner_cursor(&self, txn: Option<&Transaction>) -> Result<Cursor> {
310 self.inner.open_cursor(txn, None)
311 }
312}
313
314impl SecondaryHook for SecondaryHookState {
315 fn maintain(
316 &self,
317 txn: Option<&Transaction>,
318 pri_key: &DatabaseEntry,
319 old_data: Option<&DatabaseEntry>,
320 new_data: Option<&DatabaseEntry>,
321 ) -> Result<()> {
322 self.update_secondary(txn, pri_key, old_data, new_data)
323 }
324
325 fn name(&self) -> String {
326 self.inner.get_database_name().to_string()
327 }
328}
329
330impl FkReferrer for SecondaryHookState {
331 /// v1.6 (audit C2 / Decision 2C): handles a foreign-DB delete by
332 /// dispatching on the configured [`ForeignKeyDeleteAction`].
333 ///
334 /// * `Abort` — if any child secondary entry has `sec_key == fk_value`,
335 /// return [`NoxuError::ForeignConstraintViolation`].
336 /// * `Cascade` — wired in step 9.
337 /// * `Nullify` — wired in step 10.
338 fn on_foreign_key_deleted(
339 &self,
340 txn: Option<&Transaction>,
341 fk_value: &DatabaseEntry,
342 ) -> Result<()> {
343 match self.config.foreign_key_delete_action {
344 crate::secondary_config::ForeignKeyDeleteAction::Abort => {
345 // Probe the inner secondary index for any duplicate of
346 // `fk_value`. If we find at least one, the foreign
347 // delete must abort.
348 let mut probe_key = fk_value.clone();
349 let mut probe_pk = DatabaseEntry::new();
350 let mut cursor = self.inner.open_cursor(txn, None)?;
351 let st = cursor
352 .get(
353 &mut probe_key,
354 &mut probe_pk,
355 crate::get::Get::Search,
356 None,
357 )
358 .map_err(|e| {
359 NoxuError::OperationNotAllowed(e.to_string())
360 })?;
361 if st == OperationStatus::Success {
362 let fk_hex = fk_value
363 .get_data()
364 .map(|b| {
365 b.iter()
366 .map(|b| format!("{b:02x}"))
367 .collect::<String>()
368 })
369 .unwrap_or_default();
370 return Err(NoxuError::ForeignConstraintViolation(
371 format!(
372 "foreign-key delete aborted: secondary '{}' \
373 still references foreign key 0x{fk_hex} \
374 (ForeignKeyDeleteAction::Abort)",
375 self.inner.get_database_name()
376 ),
377 ));
378 }
379 Ok(())
380 }
381 crate::secondary_config::ForeignKeyDeleteAction::Cascade => {
382 // v1.6 step 9 — transitive cascade with cycle detection.
383 //
384 // For every primary record indexed under `fk_value` in
385 // *this* secondary, delete the primary. The primary's
386 // own [`Database::delete`] fan-out re-enters this hook
387 // for any deeper cascades; the thread-local guard keeps
388 // a cycle from spinning forever.
389 let primary = Arc::clone(&self.primary);
390 let db_id = primary.lock().db_id_for_fk_guard();
391 let fk_bytes = fk_value.get_data().unwrap_or(&[]).to_vec();
392
393 if !FK_CASCADE_GUARD
394 .with(|c| c.borrow_mut().insert((db_id, fk_bytes.clone())))
395 {
396 // Already cascading on this (db, key) frame — skip
397 // to break the cycle. This matches JE's
398 // `cascadeDeletePrimaries` cycle-skip logic.
399 return Ok(());
400 }
401
402 // Collect every child primary key indexed under fk_value.
403 let child_pris: Vec<DatabaseEntry> = {
404 let mut child_keys = Vec::new();
405 let mut cursor = self.inner.open_cursor(txn, None)?;
406 let mut sk = fk_value.clone();
407 let mut pk = DatabaseEntry::new();
408 let mut st = cursor
409 .get(&mut sk, &mut pk, crate::get::Get::Search, None)
410 .map_err(|e| {
411 NoxuError::OperationNotAllowed(e.to_string())
412 })?;
413 while st == OperationStatus::Success {
414 if sk.get_data().unwrap_or(&[])
415 != fk_value.get_data().unwrap_or(&[])
416 {
417 break;
418 }
419 if let Some(b) = pk.get_data() {
420 child_keys.push(DatabaseEntry::from_bytes(b));
421 }
422 st = cursor
423 .get(&mut sk, &mut pk, crate::get::Get::Next, None)
424 .map_err(|e| {
425 NoxuError::OperationNotAllowed(e.to_string())
426 })?;
427 }
428 child_keys
429 };
430
431 // Apply the cascade. Each `primary.delete` re-enters
432 // the maintenance plumbing on the child primary so its
433 // secondaries and any deeper FK relationships are
434 // honoured. Errors propagate so the caller's txn rolls
435 // the cascade back together with the originating delete.
436 let cascade_result: Result<()> = (|| {
437 let primary_guard = primary.lock();
438 for child_pri in child_pris {
439 primary_guard.delete(txn, &child_pri)?;
440 }
441 Ok(())
442 })();
443
444 FK_CASCADE_GUARD.with(|c| {
445 c.borrow_mut().remove(&(db_id, fk_bytes));
446 });
447
448 cascade_result
449 }
450 crate::secondary_config::ForeignKeyDeleteAction::Nullify => {
451 // v1.6 step 10 — nullify the FK field on every child
452 // primary record indexed under fk_value, then re-put
453 // the modified record so auto-maintenance cleans up
454 // the now-stale secondary entry.
455 //
456 // Cycle detection mirrors the Cascade arm: even though
457 // a Nullify cannot directly cascade through more FK
458 // edges, a child primary update may itself be a
459 // foreign-key-delete from another perspective via the
460 // auto-maintenance fan-out, so we still guard the
461 // (db, key) frame.
462 let primary = Arc::clone(&self.primary);
463 let db_id = primary.lock().db_id_for_fk_guard();
464 let fk_bytes = fk_value.get_data().unwrap_or(&[]).to_vec();
465 if !FK_CASCADE_GUARD
466 .with(|c| c.borrow_mut().insert((db_id, fk_bytes.clone())))
467 {
468 return Ok(());
469 }
470
471 let single = self.config.foreign_key_nullifier.as_deref();
472 let multi = self.config.foreign_multi_key_nullifier.as_deref();
473
474 // Collect (child_primary_key, child_primary_data) pairs.
475 let child_records: Vec<(DatabaseEntry, DatabaseEntry)> = {
476 let mut child = Vec::new();
477 let mut cursor = self.inner.open_cursor(txn, None)?;
478 let mut sk = fk_value.clone();
479 let mut pk = DatabaseEntry::new();
480 let mut st = cursor
481 .get(&mut sk, &mut pk, crate::get::Get::Search, None)
482 .map_err(|e| {
483 NoxuError::OperationNotAllowed(e.to_string())
484 })?;
485 while st == OperationStatus::Success {
486 if sk.get_data().unwrap_or(&[])
487 != fk_value.get_data().unwrap_or(&[])
488 {
489 break;
490 }
491 // Fetch the child primary's data so the
492 // nullifier sees it.
493 let child_pri = DatabaseEntry::from_bytes(
494 pk.get_data().unwrap_or(&[]),
495 );
496 let mut data = DatabaseEntry::new();
497 let g =
498 primary.lock().get(txn, &child_pri, &mut data)?;
499 if g == OperationStatus::Success {
500 child.push((child_pri, data));
501 }
502 st = cursor
503 .get(&mut sk, &mut pk, crate::get::Get::Next, None)
504 .map_err(|e| {
505 NoxuError::OperationNotAllowed(e.to_string())
506 })?;
507 }
508 child
509 };
510
511 let nullify_result: Result<()> = (|| {
512 for (child_pri, mut child_data) in child_records {
513 let modified = match (single, multi) {
514 (Some(n), _) => n.nullify_foreign_key(
515 &self.inner,
516 &mut child_data,
517 ),
518 (None, Some(mn)) => mn.nullify_foreign_key(
519 &self.inner,
520 &child_pri,
521 &mut child_data,
522 fk_value,
523 ),
524 (None, None) => {
525 return Err(NoxuError::IllegalArgument(
526 "ForeignKeyDeleteAction::Nullify requires a \
527 ForeignKeyNullifier or \
528 ForeignMultiKeyNullifier on the \
529 SecondaryConfig"
530 .to_string(),
531 ));
532 }
533 };
534 if modified {
535 // Re-put the modified record under the
536 // caller's txn. Auto-maintenance on the
537 // child primary handles clearing the stale
538 // secondary entries.
539 primary.lock().put(txn, &child_pri, &child_data)?;
540 }
541 }
542 Ok(())
543 })();
544
545 FK_CASCADE_GUARD.with(|c| {
546 c.borrow_mut().remove(&(db_id, fk_bytes));
547 });
548
549 nullify_result
550 }
551 }
552 }
553
554 fn name(&self) -> String {
555 self.inner.get_database_name().to_string()
556 }
557}
558
559/// A secondary (index) database handle.
560///
561///
562///
563/// Secondary databases are always associated with a primary database.
564/// Key characteristics:
565/// - Direct `put` calls are prohibited; use the primary database instead.
566/// - `delete` on a secondary deletes the primary record (and all its
567/// secondary index entries).
568/// - `get` returns primary record data, not secondary data.
569/// - `open_cursor` returns a [`SecondaryCursor`].
570///
571/// # v1.5 limitations
572///
573/// - **One-to-one only** (Decision 1B): a given secondary key may map to
574/// at most one primary record. Sorted-dup secondaries are planned for
575/// v1.6. Two distinct primaries that produce the same secondary key
576/// cause the second `update_secondary` to fail with
577/// [`NoxuError::Unsupported`].
578/// - **Foreign-key constraints not enforced** (Decision 2C):
579/// [`SecondaryDatabase::open`] rejects [`SecondaryConfig`]s whose
580/// foreign-key fields are set. Full FK support is planned for v1.6.
581/// - **No automatic maintenance**: callers manually invoke
582/// [`update_secondary`](Self::update_secondary) after each primary
583/// `put` / `delete`. An automatic `associate()`-style hook is planned
584/// for v1.6.
585///
586/// # Atomicity with the primary write
587///
588/// `update_secondary` participates in the
589/// caller's transaction when one is supplied. Threading the same
590/// `txn` through both [`Database::put`] and
591/// [`update_secondary`](Self::update_secondary) makes the primary +
592/// secondary update **atomic**: aborting the txn rolls both back,
593/// committing the txn persists both. Passing `None` runs each call
594/// auto-committed, which restores the v1.4 behaviour and is acceptable
595/// when the caller does not need cross-database atomicity.
596///
597/// See `docs/src/internal/v1.5-decisions-2026-05.md` and
598/// `docs/src/transactions/secondary-with-txn.md`.
599///
600/// # Example
601/// ```ignore
602/// use noxu_db::{Database, DatabaseEntry};
603/// use noxu_db::secondary_config::{SecondaryConfig, SecondaryKeyCreator};
604/// use noxu_db::secondary_database::SecondaryDatabase;
605///
606/// struct MyKeyCreator;
607/// impl SecondaryKeyCreator for MyKeyCreator { /* ... */ }
608///
609/// let sec_config = SecondaryConfig::new()
610/// .with_allow_create(true)
611/// .with_allow_populate(true)
612/// .with_key_creator(Box::new(MyKeyCreator));
613///
614/// let secondary = SecondaryDatabase::open(primary_db, "my_index", sec_config)?;
615/// ```
616pub struct SecondaryDatabase {
617 /// All shared state behind an `Arc` so the primary registry can
618 /// keep `Weak<dyn SecondaryHook + Send + Sync>` references
619 /// (Decision 1B / audit C3). Every public method on
620 /// `SecondaryDatabase` accesses the fields through `state.field`.
621 state: Arc<SecondaryHookState>,
622}
623
624impl SecondaryDatabase {
625 /// Opens or creates a secondary database associated with `primary`.
626 ///
627 ///
628 ///
629 /// # Arguments
630 /// * `primary` - The primary database handle, shared via `Arc<Mutex<_>>`.
631 /// * `secondary_db` - An already-opened `Database` that will serve as the
632 /// underlying storage for the secondary index.
633 /// * `config` - The secondary configuration (must include a key creator).
634 ///
635 /// # Errors
636 /// - [`NoxuError::IllegalArgument`] if the configuration is invalid,
637 /// or if the inner `secondary_db` was not opened with
638 /// `DatabaseConfig::with_sorted_duplicates(true)` (v1.6 sorted-dup
639 /// secondaries — closes audit C4).
640 /// - [`NoxuError::Unsupported`] if the configuration sets any foreign-key
641 /// constraint field (`foreign_key_database`,
642 /// `foreign_key_delete_action != Abort`, `foreign_key_nullifier`, or
643 /// `foreign_multi_key_nullifier`). v1.5 does not enforce FK
644 /// constraints; full FK support is planned for v1.6 — see Decision 2C
645 /// in `docs/src/internal/v1.5-decisions-2026-05.md` (closes audit
646 /// findings C2 / F1 / F16).
647 pub fn open(
648 primary: Arc<Mutex<Database>>,
649 secondary_db: Database,
650 config: SecondaryConfig,
651 ) -> Result<Self> {
652 // Validate the config w.r.t. the primary's read-only flag.
653 let primary_read_only = primary.lock().get_config().read_only;
654 config
655 .validate(primary_read_only)
656 .map_err(NoxuError::IllegalArgument)?;
657
658 // v1.6 (Decision 1B / audit C4): the inner secondary index DB
659 // must be opened with sorted_duplicates so multiple primary
660 // records can share the same secondary key as duplicates of
661 // the (sec_key) entry. Reject otherwise — in v1.5 we used
662 // Put::NoOverwrite and surfaced cross-primary collisions as
663 // NoxuError::Unsupported; v1.6 stores them as duplicates.
664 if !secondary_db.get_config().sorted_duplicates {
665 return Err(NoxuError::IllegalArgument(
666 "v1.6 secondary databases require the inner index DB to \
667 be opened with DatabaseConfig::with_sorted_duplicates(true) \
668 — see docs/src/internal/v1.5-decisions-2026-05.md Decision 1B"
669 .to_string(),
670 ));
671 }
672
673 // v1.6 (audit C2 / Decision 2C): foreign-key constraints are
674 // now enforced when the user supplies the foreign DB handle
675 // via [`SecondaryConfig::with_foreign_key_database_handle`].
676 // The `name`-only setter remains advisory — a config that
677 // names a foreign DB but never wires the handle is rejected
678 // here so the user is not silently left with an unenforced
679 // constraint. Cascade / Nullify still require the handle and
680 // the matching nullifier (steps 9 / 10).
681 let fk_handle = config.foreign_key_database.clone();
682 if config.foreign_key_database_name.is_some() && fk_handle.is_none() {
683 return Err(NoxuError::IllegalArgument(
684 "SecondaryConfig.foreign_key_database_name is set without \
685 a foreign_key_database handle; v1.6 FK enforcement requires \
686 calling SecondaryConfig::with_foreign_key_database_handle()"
687 .to_string(),
688 ));
689 }
690 if (config.foreign_key_nullifier.is_some()
691 || config.foreign_multi_key_nullifier.is_some())
692 && fk_handle.is_none()
693 {
694 return Err(NoxuError::IllegalArgument(
695 "foreign-key nullifier is set without a foreign_key_database \
696 handle (call SecondaryConfig::with_foreign_key_database_handle)"
697 .to_string(),
698 ));
699 }
700
701 let state = Arc::new(SecondaryHookState {
702 inner: secondary_db,
703 primary,
704 config,
705 is_fully_populated: AtomicBool::new(true),
706 });
707
708 // v1.6 (audit C3): register the secondary on the primary so
709 // future `Database::put` / `Database::delete` calls fan out to
710 // it automatically. We downgrade to `Weak` so dropping the
711 // `SecondaryDatabase` handle removes it from the registry on
712 // the next iteration.
713 {
714 let weak: std::sync::Weak<dyn SecondaryHook + Send + Sync> =
715 Arc::downgrade(&state) as _;
716 state.primary.lock().register_secondary(weak);
717 }
718
719 // v1.6 (audit C2 / Decision 2C): if the secondary references a
720 // foreign primary DB, register as an FK referrer there so its
721 // `Database::delete` can call back into us with the configured
722 // ForeignKeyDeleteAction.
723 if let Some(fk_handle) = fk_handle {
724 let weak: std::sync::Weak<dyn FkReferrer + Send + Sync> =
725 Arc::downgrade(&state) as _;
726 fk_handle.lock().register_fk_referrer(weak);
727 }
728
729 let sec = SecondaryDatabase { state };
730
731 // If allow_populate and the secondary is empty, populate from primary.
732 if sec.state.config.allow_populate {
733 sec.populate_if_empty()?;
734 }
735
736 Ok(sec)
737 }
738
739 // ------------------------------------------------------------------
740 // Public API
741 // ------------------------------------------------------------------
742
743 /// Returns the database name of the secondary index.
744 pub fn get_database_name(&self) -> &str {
745 self.state.inner.get_database_name()
746 }
747
748 /// Returns the secondary configuration.
749 ///
750 ///
751 pub fn get_config(&self) -> &SecondaryConfig {
752 &self.state.config
753 }
754
755 /// Returns whether this handle is open.
756 pub fn is_valid(&self) -> bool {
757 self.state.inner.is_valid()
758 }
759
760 /// Closes the secondary database handle.
761 ///
762 ///
763 pub fn close(&self) -> Result<()> {
764 self.state.inner.close()
765 }
766
767 /// Returns the number of records in the secondary index.
768 ///
769 /// Equivalent to `Database::count` on the underlying inner index
770 /// database; included on `SecondaryDatabase` for symmetry with JE's
771 /// `SecondaryDatabase.count()` method. See
772 /// (secondary-join “missing count/exists/truncate” Low).
773 ///
774 /// # Errors
775 /// Returns [`NoxuError::DatabaseClosed`] if the secondary handle has
776 /// been closed.
777 pub fn count(&self) -> Result<u64> {
778 self.state.inner.count()
779 }
780
781 /// Returns `true` if any record with the given secondary key exists.
782 ///
783 /// This avoids the cost of reading the primary record — unlike
784 /// [`Self::get`], which traverses the secondary, then the primary
785 /// database. Useful for membership probes inside hot paths.
786 ///
787 /// # Errors
788 /// Propagates any error from the underlying secondary lookup.
789 pub fn exists(
790 &self,
791 txn: Option<&Transaction>,
792 key: &DatabaseEntry,
793 ) -> Result<bool> {
794 let mut data = DatabaseEntry::new();
795 let status = self.state.inner.get(txn, key, &mut data)?;
796 Ok(status == OperationStatus::Success)
797 }
798
799 /// Removes every record from the secondary index, leaving the
800 /// associated primary database untouched.
801 ///
802 /// **Caveat.** Truncating a secondary index without re-running
803 /// `populate_if_empty` (or replaying the primary-side updates)
804 /// leaves the secondary in a state that is not consistent with the
805 /// primary. Most callers should drop the secondary's primary keys
806 /// via `Database::truncate_database` on the inner DB or repopulate
807 /// the index afterwards. Returned for symmetry with JE's
808 /// `SecondaryDatabase.truncate(...)`.
809 ///
810 /// Returns the number of records that were in the index before the
811 /// truncate.
812 ///
813 /// # Errors
814 /// Returns [`NoxuError::DatabaseClosed`] if the secondary handle has
815 /// been closed, or any error returned by the underlying delete
816 /// loop.
817 pub fn truncate(&self) -> Result<u64> {
818 let pre = self.count()?;
819 // Walk every (sec_key, pri_key) pair via a primary-table-style
820 // scan and delete each. The inner index is an ordinary
821 // Database, so this is just a cursor scan + delete.
822 let mut cursor = self.state.inner.open_cursor(None, None)?;
823 let mut sec_key = DatabaseEntry::new();
824 let mut data = DatabaseEntry::new();
825 // get_first returns NotFound if the index is empty.
826 if cursor.get(&mut sec_key, &mut data, crate::get::Get::First, None)?
827 != OperationStatus::Success
828 {
829 return Ok(0);
830 }
831 loop {
832 cursor.delete()?;
833 match cursor.get(
834 &mut sec_key,
835 &mut data,
836 crate::get::Get::Next,
837 None,
838 )? {
839 OperationStatus::Success => continue,
840 _ => break,
841 }
842 }
843 Ok(pre)
844 }
845
846 /// Retrieves a primary record by secondary key.
847 ///
848 ///
849 ///
850 /// Looks up `key` in the secondary index, obtains the primary key stored
851 /// there, then fetches the corresponding record from the primary database.
852 ///
853 /// # Arguments
854 /// * `txn` - Optional transaction.
855 /// * `key` - The secondary key to search for.
856 /// * `p_key` - Output: receives the primary key found.
857 /// * `data` - Output: receives the primary record data.
858 ///
859 /// # Returns
860 /// `OperationStatus::Success` if found; `OperationStatus::NotFound` otherwise.
861 pub fn get(
862 &self,
863 txn: Option<&Transaction>,
864 key: &DatabaseEntry,
865 p_key: &mut DatabaseEntry,
866 data: &mut DatabaseEntry,
867 ) -> Result<OperationStatus> {
868 self.check_open()?;
869 self.check_readable()?;
870
871 // Look up the secondary key in the index to get the primary key.
872 let mut pri_key_entry = DatabaseEntry::new();
873 let status = self.state.inner.get(txn, key, &mut pri_key_entry)?;
874
875 if status != OperationStatus::Success {
876 return Ok(OperationStatus::NotFound);
877 }
878
879 // Store the primary key in the output parameter.
880 if let Some(pk) = pri_key_entry.get_data() {
881 p_key.set_data(pk);
882 }
883
884 // Now fetch the primary record.
885 let primary = self.state.primary.lock();
886 let pri_status = primary.get(txn, &pri_key_entry, data)?;
887 if pri_status != OperationStatus::Success {
888 // Secondary refers to a missing primary — integrity issue.
889 return Err(NoxuError::SecondaryIntegrityException(format!(
890 "Secondary '{}' refers to missing primary key",
891 self.get_database_name()
892 )));
893 }
894
895 Ok(OperationStatus::Success)
896 }
897
898 /// Deletes all primary records whose secondary key equals `key`.
899 ///
900 ///
901 ///
902 /// All duplicate secondary index entries with the given secondary key are
903 /// found and their corresponding primary records deleted. Each primary
904 /// deletion in turn removes all secondary index entries for that primary
905 /// record.
906 ///
907 /// # Arguments
908 /// * `txn` - Optional transaction.
909 /// * `key` - The secondary key whose primary records should be deleted.
910 ///
911 /// # Returns
912 /// `OperationStatus::Success` if at least one record was deleted;
913 /// `OperationStatus::NotFound` if the key was not found.
914 pub fn delete(
915 &self,
916 txn: Option<&Transaction>,
917 key: &DatabaseEntry,
918 ) -> Result<OperationStatus> {
919 self.check_open()?;
920
921 // Use a secondary cursor (under the caller's txn so the scan
922 // participates in the user's transaction) to iterate all
923 // duplicates of the secondary key.
924 let mut sec_cursor = self.open_cursor_internal(txn)?;
925 let mut p_key = DatabaseEntry::new();
926 let mut data = DatabaseEntry::new();
927
928 // Position to the first record with this secondary key.
929 let status = sec_cursor.get_search_key(key, &mut p_key, &mut data)?;
930
931 if status != OperationStatus::Success {
932 return Ok(OperationStatus::NotFound);
933 }
934
935 // We found at least one; iterate and delete all matching primary records.
936 loop {
937 let pri_key_bytes = p_key.get_data().unwrap_or(&[]).to_vec();
938 let pri_key_entry = DatabaseEntry::from_bytes(&pri_key_bytes);
939
940 // 1. Remove all secondary entries for this primary record first.
941 // This includes the current secondary key entry we found.
942 // UpdateSecondaryOnDelete calls updateSecondary. Sprint 4½
943 // forwards `txn` so the cleanup is atomic with the primary
944 // delete below.
945 let old_data = data.clone();
946 self.delete_all_for_primary(txn, &pri_key_entry, Some(&old_data))?;
947
948 // 2. Delete the primary record.
949 {
950 let primary = self.state.primary.lock();
951 let _ = primary.delete(txn, &pri_key_entry)?;
952 }
953
954 // Re-search for the key to find any remaining duplicates.
955 // Since delete_all_for_primary cleaned up secondary entries,
956 // this should return NotFound when no more duplicates exist.
957 p_key = DatabaseEntry::new();
958 data = DatabaseEntry::new();
959 let next_status =
960 sec_cursor.get_search_key(key, &mut p_key, &mut data)?;
961 if next_status != OperationStatus::Success {
962 break;
963 }
964 }
965
966 Ok(OperationStatus::Success)
967 }
968
969 /// Opens a cursor on the secondary database.
970 ///
971 /// When `txn` is `Some(_)`, the inner cursor over the secondary
972 /// index participates in the supplied transaction — reads acquire
973 /// shared locks via the txn's locker and writes acquire exclusive
974 /// locks tracked by the txn. Secondary cursors also
975 /// this to the *primary* lookups and the
976 /// [`SecondaryCursor::delete`] cascade as well: the cursor stores
977 /// the txn handle and forwards it to every primary `get` /
978 /// `delete` and to `delete_all_for_primary`. Aborting the txn
979 /// rolls back **both** the secondary entry and the primary record
980 /// removed by `SecondaryCursor::delete` (and every secondary
981 /// cleanup it triggers). When `txn` is `None`, every operation
982 /// runs auto-committed, matching the v1.4 behaviour.
983 ///
984 /// `config` is forwarded to the inner `Database::open_cursor` call so
985 /// `read_uncommitted` and other cursor-level flags propagate correctly.
986 ///
987 /// # Lifetime contract
988 ///
989 /// The returned [`SecondaryCursor`] borrows both the
990 /// `SecondaryDatabase` and — when supplied — the `Transaction`,
991 /// because primary deletes and cleanup writes are deferred until
992 /// `SecondaryCursor::delete` is called. Callers must therefore
993 /// keep the `Transaction` alive at least as long as the cursor.
994 /// In practice this is the same lifetime rule that already applies
995 /// to [`Database::open_cursor`]; it is now enforced statically by
996 /// the type system.
997 ///
998 /// # Returns
999 /// A `SecondaryCursor` that iterates secondary index entries and returns
1000 /// primary data.
1001 pub fn open_cursor<'a>(
1002 &'a self,
1003 txn: Option<&'a Transaction>,
1004 config: Option<&CursorConfig>,
1005 ) -> Result<SecondaryCursor<'a>> {
1006 self.check_open()?;
1007 self.check_readable()?;
1008 SecondaryCursor::new(self, txn, config)
1009 }
1010
1011 /// Starts incremental population mode.
1012 ///
1013 ///
1014 pub fn start_incremental_population(&self) {
1015 self.state.is_fully_populated.store(false, Ordering::Release);
1016 }
1017
1018 /// Ends incremental population mode.
1019 ///
1020 ///
1021 pub fn end_incremental_population(&self) {
1022 self.state.is_fully_populated.store(true, Ordering::Release);
1023 }
1024
1025 /// Returns whether incremental population is currently enabled.
1026 ///
1027 ///
1028 pub fn is_incremental_population_enabled(&self) -> bool {
1029 !self.state.is_fully_populated.load(Ordering::Acquire)
1030 }
1031
1032 // ------------------------------------------------------------------
1033 // Internal helpers called by Database and SecondaryCursor
1034 // ------------------------------------------------------------------
1035
1036 /// Updates the secondary index when a primary record is inserted or updated.
1037 ///
1038 /// Called from application code that manages secondary index updates
1039 /// manually (v1.5 has no automatic `associate()`-style hook — that is
1040 /// v1.6 work).
1041 ///
1042 /// # Atomicity
1043 ///
1044 /// When `txn` is `Some(&t)`, **all** I/O performed by this method
1045 /// (cursor opens, `insert_sec_key`, `delete_sec_key`) is executed
1046 /// under `t`. If the caller used the same `t` for the primary
1047 /// [`Database::put`] / [`Database::delete`] that prompted this
1048 /// update, the primary write and every affected secondary index
1049 /// entry commit or abort together. This is the recommended
1050 /// pattern; see `docs/src/transactions/secondary-with-txn.md`.
1051 ///
1052 /// When `txn` is `None`, every inner secondary write runs
1053 /// auto-committed (v1.4 behaviour). This is intentionally
1054 /// available so callers that do not need cross-database atomicity
1055 /// — e.g. one-shot population or single-threaded scripts — do not
1056 /// need to allocate a transaction.
1057 ///
1058 /// **Idempotent re-insert** (Decision 1B): if `update_secondary` is
1059 /// invoked twice with the same `(sec_key, pri_key)` pair (whether
1060 /// auto-commit or under the same `txn`), the second call is a
1061 /// no-op rather than a [`NoxuError::Unsupported`] collision — see
1062 /// `Self::insert_sec_key`.
1063 ///
1064 /// # Arguments
1065 /// * `txn` - Optional transaction. Pass the same handle that
1066 /// drives the primary write to make both updates atomic.
1067 /// * `pri_key` - The primary key.
1068 /// * `old_data` - The previous primary data, or `None` on insert.
1069 /// * `new_data` - The new primary data, or `None` on delete.
1070 pub fn update_secondary(
1071 &self,
1072 txn: Option<&Transaction>,
1073 pri_key: &DatabaseEntry,
1074 old_data: Option<&DatabaseEntry>,
1075 new_data: Option<&DatabaseEntry>,
1076 ) -> Result<()> {
1077 // Delegated to the state so the [`SecondaryHook`] trait impl can
1078 // share the same body when `Database::put` / `Database::delete`
1079 // drives automatic maintenance (audit C3).
1080 self.state.update_secondary(txn, pri_key, old_data, new_data)
1081 }
1082
1083 /// Removes all secondary index entries for the given primary key.
1084 ///
1085 /// Called when a primary record is deleted. `txn` is forwarded to
1086 /// [`Self::update_secondary`] so the cleanup participates in the
1087 /// caller's transaction.
1088 pub(crate) fn delete_all_for_primary(
1089 &self,
1090 txn: Option<&Transaction>,
1091 pri_key: &DatabaseEntry,
1092 old_data: Option<&DatabaseEntry>,
1093 ) -> Result<()> {
1094 self.state.update_secondary(txn, pri_key, old_data, None)
1095 }
1096
1097 /// Returns a reference to the inner index `Database`.
1098 pub(crate) fn inner_db(&self) -> &Database {
1099 &self.state.inner
1100 }
1101
1102 /// Returns a reference to the primary `Database` (via the mutex).
1103 pub(crate) fn primary_db(&self) -> &Arc<Mutex<Database>> {
1104 &self.state.primary
1105 }
1106
1107 // ------------------------------------------------------------------
1108 // Private helpers
1109 // ------------------------------------------------------------------
1110
1111 /// Inserts a secondary index entry: (sec_key -> pri_key).
1112 ///
1113 /// v1.6 (Decision 1B / audit C4): the inner index DB is sorted-dup,
1114 /// so multiple primary records that produce the same `sec_key` are
1115 /// stored as duplicates of `sec_key`. Delegates to the state-side
1116 /// implementation so the [`SecondaryHook`] trait shares it.
1117 fn insert_sec_key(
1118 &self,
1119 txn: Option<&Transaction>,
1120 sec_key: &DatabaseEntry,
1121 pri_key: &DatabaseEntry,
1122 ) -> Result<()> {
1123 self.state.insert_sec_key(txn, sec_key, pri_key)
1124 }
1125
1126 /// Deletes a secondary index entry: (sec_key -> pri_key). Delegates
1127 /// to the state-side implementation.
1128 #[allow(dead_code)]
1129 fn delete_sec_key(
1130 &self,
1131 txn: Option<&Transaction>,
1132 sec_key: &DatabaseEntry,
1133 pri_key: &DatabaseEntry,
1134 ) -> Result<()> {
1135 self.state.delete_sec_key(txn, sec_key, pri_key)
1136 }
1137
1138 /// Builds a writable `Cursor` on the inner secondary index `Database`.
1139 /// Delegates to the state-side implementation.
1140 #[allow(dead_code)]
1141 fn make_inner_cursor(&self, txn: Option<&Transaction>) -> Result<Cursor> {
1142 self.state.inner.open_cursor(txn, None)
1143 }
1144
1145 /// Builds a `SecondaryCursor` on this secondary database (internal).
1146 ///
1147 /// `txn` is forwarded to [`SecondaryCursor::new`] so all inner-database
1148 /// reads and the cascade primary delete participate in the caller's
1149 /// transaction. Used from
1150 /// [`SecondaryDatabase::delete`] to drive the secondary scan under
1151 /// the caller's txn.
1152 fn open_cursor_internal<'a>(
1153 &'a self,
1154 txn: Option<&'a Transaction>,
1155 ) -> Result<SecondaryCursor<'a>> {
1156 SecondaryCursor::new(self, txn, None)
1157 }
1158
1159 /// Populates the secondary index from the primary if the secondary is empty.
1160 ///
1161 /// Population logic in `SecondaryDatabase.init`.
1162 fn populate_if_empty(&self) -> Result<()> {
1163 // Check if the secondary is empty.
1164 let sec_count = self.state.inner.count()?;
1165 if sec_count > 0 {
1166 return Ok(());
1167 }
1168
1169 // Use direct CursorImpl scan to access both key and value.
1170 let primary = self.state.primary.lock();
1171 self.populate_from_primary_scan(&primary)?;
1172
1173 Ok(())
1174 }
1175
1176 /// Scans the primary database and inserts secondary index entries.
1177 fn populate_from_primary_scan(&self, primary: &Database) -> Result<()> {
1178 // We access the inner DatabaseImpl directly to read both key and value.
1179 // The public Cursor::get API currently only returns data, not key.
1180 // Use a dedicated scan loop via CursorImpl.
1181 let mut cursor = CursorImpl::new(Arc::clone(&primary.db_impl), 0);
1182
1183 let mut first_status = cursor
1184 .get_first()
1185 .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
1186
1187 while first_status == noxu_dbi::OperationStatus::Success {
1188 let (k, v) = cursor
1189 .get_current()
1190 .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
1191
1192 let pri_key = DatabaseEntry::from_bytes(&k);
1193 let pri_data = DatabaseEntry::from_bytes(&v);
1194
1195 // Create secondary key(s) and insert them. Population runs
1196 // at `SecondaryDatabase::open` time, before any user txn
1197 // exists, so we auto-commit each insert (`txn = None`).
1198 if let Some(creator) = &self.state.config.key_creator {
1199 let mut sec_key = DatabaseEntry::new();
1200 if creator.create_secondary_key(
1201 &self.state.inner,
1202 &pri_key,
1203 &pri_data,
1204 &mut sec_key,
1205 ) {
1206 self.insert_sec_key(None, &sec_key, &pri_key)?;
1207 }
1208 } else if let Some(multi_creator) =
1209 &self.state.config.multi_key_creator
1210 {
1211 let mut sec_keys = Vec::new();
1212 multi_creator.create_secondary_keys(
1213 &self.state.inner,
1214 &pri_key,
1215 &pri_data,
1216 &mut sec_keys,
1217 );
1218 for sec_key in sec_keys {
1219 self.insert_sec_key(None, &sec_key, &pri_key)?;
1220 }
1221 }
1222
1223 first_status = cursor
1224 .retrieve_next(GetMode::Next)
1225 .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
1226 }
1227
1228 Ok(())
1229 }
1230
1231 /// Checks that this database is open.
1232 fn check_open(&self) -> Result<()> {
1233 if !self.state.inner.is_valid() {
1234 return Err(NoxuError::DatabaseClosed);
1235 }
1236 Ok(())
1237 }
1238
1239 /// Checks that this database is readable (not in incremental population mode).
1240 fn check_readable(&self) -> Result<()> {
1241 if !self.state.is_fully_populated.load(Ordering::Acquire) {
1242 return Err(NoxuError::OperationNotAllowed(
1243 "Incremental population is currently enabled".to_string(),
1244 ));
1245 }
1246 Ok(())
1247 }
1248}
1249
1250impl Drop for SecondaryDatabase {
1251 fn drop(&mut self) {
1252 let _ = self.close();
1253 }
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258 use super::*;
1259 use crate::database_config::DatabaseConfig;
1260 use crate::environment::Environment;
1261 use crate::environment_config::EnvironmentConfig;
1262 use crate::secondary_config::{SecondaryConfig, SecondaryKeyCreator};
1263 use tempfile::TempDir;
1264
1265 /// A simple key creator that uses the first byte of the value as the
1266 /// secondary key.
1267 struct FirstByteKeyCreator;
1268
1269 impl SecondaryKeyCreator for FirstByteKeyCreator {
1270 fn create_secondary_key(
1271 &self,
1272 _db: &Database,
1273 _key: &DatabaseEntry,
1274 data: &DatabaseEntry,
1275 result: &mut DatabaseEntry,
1276 ) -> bool {
1277 if let Some(d) = data.get_data()
1278 && !d.is_empty()
1279 {
1280 result.set_data(&d[..1]);
1281 return true;
1282 }
1283 false
1284 }
1285 }
1286
1287 fn temp_env() -> (TempDir, Environment) {
1288 let temp_dir = TempDir::new().unwrap();
1289 let env_config = EnvironmentConfig::new(temp_dir.path().to_path_buf())
1290 .with_allow_create(true)
1291 .with_transactional(true);
1292 let env = Environment::open(env_config).unwrap();
1293 (temp_dir, env)
1294 }
1295
1296 fn open_primary(env: &Environment, name: &str) -> Database {
1297 let config = DatabaseConfig::new().with_allow_create(true);
1298 env.open_database(None, name, &config).unwrap()
1299 }
1300
1301 fn open_secondary(
1302 primary: Arc<Mutex<Database>>,
1303 env: &Environment,
1304 name: &str,
1305 ) -> SecondaryDatabase {
1306 // v1.6 sorted-dup secondaries: inner index DB must allow dups.
1307 let sec_db_config = DatabaseConfig::new()
1308 .with_allow_create(true)
1309 .with_sorted_duplicates(true);
1310 let sec_db = env.open_database(None, name, &sec_db_config).unwrap();
1311 let sec_config = SecondaryConfig::new()
1312 .with_allow_create(true)
1313 .with_key_creator(Box::new(FirstByteKeyCreator));
1314 SecondaryDatabase::open(primary, sec_db, sec_config).unwrap()
1315 }
1316
1317 #[test]
1318 fn test_open_secondary() {
1319 let (_tmp, env) = temp_env();
1320 let primary = Arc::new(Mutex::new(open_primary(&env, "primary")));
1321 let secondary = open_secondary(Arc::clone(&primary), &env, "secondary");
1322 assert!(secondary.is_valid());
1323 assert_eq!(secondary.get_database_name(), "secondary");
1324 }
1325
1326 #[test]
1327 fn test_put_primary_updates_secondary() {
1328 let (_tmp, env) = temp_env();
1329 let primary = Arc::new(Mutex::new(open_primary(&env, "primary")));
1330 let secondary = open_secondary(Arc::clone(&primary), &env, "secondary");
1331
1332 // Write to primary; secondary is not auto-updated here because
1333 // Database::put does not know about secondaries by default.
1334 // We manually call update_secondary for this test.
1335 let pri_key = DatabaseEntry::from_bytes(b"pk1");
1336 let pri_data = DatabaseEntry::from_bytes(b"Avalon");
1337 {
1338 let primary = primary.lock();
1339 primary.put(None, &pri_key, &pri_data).unwrap();
1340 }
1341
1342 // Update the secondary index manually (mimics the integration layer).
1343 secondary
1344 .update_secondary(None, &pri_key, None, Some(&pri_data))
1345 .unwrap();
1346
1347 // Retrieve by secondary key (first byte of "Avalon" = 'A' = 0x41).
1348 let sec_key = DatabaseEntry::from_bytes(b"A");
1349 let mut p_key = DatabaseEntry::new();
1350 let mut data = DatabaseEntry::new();
1351 let status =
1352 secondary.get(None, &sec_key, &mut p_key, &mut data).unwrap();
1353
1354 assert_eq!(status, OperationStatus::Success);
1355 assert_eq!(p_key.get_data().unwrap(), b"pk1");
1356 assert_eq!(data.get_data().unwrap(), b"Avalon");
1357 }
1358
1359 #[test]
1360 fn test_get_by_secondary_key() {
1361 let (_tmp, env) = temp_env();
1362 let primary = Arc::new(Mutex::new(open_primary(&env, "primary")));
1363 let secondary = open_secondary(Arc::clone(&primary), &env, "secondary");
1364
1365 // Insert primary records and index them. Each record uses a
1366 // distinct first byte so the v1.5 one-to-one secondary contract
1367 // (Decision 1B) is satisfied.
1368 let records: &[(&[u8], &[u8])] =
1369 &[(b"pk1", b"Apple"), (b"pk2", b"Banana"), (b"pk3", b"Cherry")];
1370
1371 for (k, v) in records {
1372 let pk = DatabaseEntry::from_bytes(k);
1373 let pv = DatabaseEntry::from_bytes(v);
1374 {
1375 primary.lock().put(None, &pk, &pv).unwrap();
1376 }
1377 secondary.update_secondary(None, &pk, None, Some(&pv)).unwrap();
1378 }
1379
1380 // Search by secondary key 'B'.
1381 let sec_key = DatabaseEntry::from_bytes(b"B");
1382 let mut p_key = DatabaseEntry::new();
1383 let mut data = DatabaseEntry::new();
1384 let status =
1385 secondary.get(None, &sec_key, &mut p_key, &mut data).unwrap();
1386
1387 assert_eq!(status, OperationStatus::Success);
1388 assert_eq!(data.get_data().unwrap(), b"Banana");
1389
1390 // Search for non-existent secondary key.
1391 let missing = DatabaseEntry::from_bytes(b"Z");
1392 let status =
1393 secondary.get(None, &missing, &mut p_key, &mut data).unwrap();
1394 assert_eq!(status, OperationStatus::NotFound);
1395 }
1396
1397 #[test]
1398 fn test_delete_via_secondary() {
1399 let (_tmp, env) = temp_env();
1400 let primary = Arc::new(Mutex::new(open_primary(&env, "primary")));
1401 let secondary = open_secondary(Arc::clone(&primary), &env, "secondary");
1402
1403 let pri_key = DatabaseEntry::from_bytes(b"pk1");
1404 let pri_data = DatabaseEntry::from_bytes(b"Cherry");
1405 {
1406 primary.lock().put(None, &pri_key, &pri_data).unwrap();
1407 }
1408 secondary
1409 .update_secondary(None, &pri_key, None, Some(&pri_data))
1410 .unwrap();
1411
1412 // Delete via secondary key.
1413 let sec_key = DatabaseEntry::from_bytes(b"C");
1414 let status = secondary.delete(None, &sec_key).unwrap();
1415 assert_eq!(status, OperationStatus::Success);
1416
1417 // Primary record should be gone.
1418 let mut data = DatabaseEntry::new();
1419 let get_status = primary.lock().get(None, &pri_key, &mut data).unwrap();
1420 assert_eq!(get_status, OperationStatus::NotFound);
1421 }
1422
1423 #[test]
1424 fn test_update_changes_secondary_key() {
1425 let (_tmp, env) = temp_env();
1426 let primary = Arc::new(Mutex::new(open_primary(&env, "primary")));
1427 let secondary = open_secondary(Arc::clone(&primary), &env, "secondary");
1428
1429 let pri_key = DatabaseEntry::from_bytes(b"pk1");
1430 let old_data = DatabaseEntry::from_bytes(b"Mango");
1431 let new_data = DatabaseEntry::from_bytes(b"Pineapple");
1432
1433 {
1434 primary.lock().put(None, &pri_key, &old_data).unwrap();
1435 }
1436 secondary
1437 .update_secondary(None, &pri_key, None, Some(&old_data))
1438 .unwrap();
1439
1440 // Now update the primary; the secondary key 'M' should be replaced by 'P'.
1441 {
1442 primary.lock().put(None, &pri_key, &new_data).unwrap();
1443 }
1444 secondary
1445 .update_secondary(None, &pri_key, Some(&old_data), Some(&new_data))
1446 .unwrap();
1447
1448 // Old key 'M' should no longer be in the secondary.
1449 let old_sec = DatabaseEntry::from_bytes(b"M");
1450 let mut pk = DatabaseEntry::new();
1451 let mut data = DatabaseEntry::new();
1452 let status = secondary.get(None, &old_sec, &mut pk, &mut data).unwrap();
1453 assert_eq!(status, OperationStatus::NotFound);
1454
1455 // New key 'P' should be present.
1456 let new_sec = DatabaseEntry::from_bytes(b"P");
1457 let status = secondary.get(None, &new_sec, &mut pk, &mut data).unwrap();
1458 assert_eq!(status, OperationStatus::Success);
1459 assert_eq!(data.get_data().unwrap(), b"Pineapple");
1460 }
1461
1462 #[test]
1463 fn test_cursor_scan_secondary() {
1464 let (_tmp, env) = temp_env();
1465 let primary = Arc::new(Mutex::new(open_primary(&env, "primary")));
1466 let secondary = open_secondary(Arc::clone(&primary), &env, "secondary");
1467
1468 // Insert records with distinct first bytes.
1469 let records: &[(&[u8], &[u8])] =
1470 &[(b"pk1", b"Banana"), (b"pk2", b"Cherry"), (b"pk3", b"Apple")];
1471 for (k, v) in records {
1472 let pk = DatabaseEntry::from_bytes(k);
1473 let pv = DatabaseEntry::from_bytes(v);
1474 primary.lock().put(None, &pk, &pv).unwrap();
1475 secondary.update_secondary(None, &pk, None, Some(&pv)).unwrap();
1476 }
1477
1478 // Iterate via SecondaryCursor and collect all secondary keys encountered.
1479 let mut cursor = secondary.open_cursor(None, None).unwrap();
1480 let mut sec_keys_seen: Vec<Vec<u8>> = Vec::new();
1481 let mut sec_key = DatabaseEntry::new();
1482 let mut p_key = DatabaseEntry::new();
1483 let mut data = DatabaseEntry::new();
1484
1485 let status =
1486 cursor.get_first(&mut sec_key, &mut p_key, &mut data).unwrap();
1487 let mut current = status;
1488 while current == OperationStatus::Success {
1489 if let Some(k) = sec_key.get_data() {
1490 sec_keys_seen.push(k.to_vec());
1491 }
1492 current =
1493 cursor.get_next(&mut sec_key, &mut p_key, &mut data).unwrap();
1494 }
1495
1496 // We expect 3 entries (A, B, C in secondary key order).
1497 assert_eq!(sec_keys_seen.len(), 3);
1498 assert_eq!(sec_keys_seen[0], b"A");
1499 assert_eq!(sec_keys_seen[1], b"B");
1500 assert_eq!(sec_keys_seen[2], b"C");
1501 }
1502
1503 #[test]
1504 fn test_incremental_population() {
1505 let (_tmp, env) = temp_env();
1506 let primary = Arc::new(Mutex::new(open_primary(&env, "primary")));
1507 let secondary = open_secondary(Arc::clone(&primary), &env, "secondary");
1508
1509 secondary.start_incremental_population();
1510 assert!(secondary.is_incremental_population_enabled());
1511
1512 // Reads should fail during incremental population.
1513 let sec_key = DatabaseEntry::from_bytes(b"A");
1514 let mut pk = DatabaseEntry::new();
1515 let mut data = DatabaseEntry::new();
1516 let result = secondary.get(None, &sec_key, &mut pk, &mut data);
1517 assert!(result.is_err());
1518
1519 secondary.end_incremental_population();
1520 assert!(!secondary.is_incremental_population_enabled());
1521 }
1522
1523 #[test]
1524 fn test_populate_on_open() {
1525 let (_tmp, env) = temp_env();
1526 let primary = Arc::new(Mutex::new(open_primary(&env, "primary")));
1527
1528 // Pre-populate the primary.
1529 let records: &[(&[u8], &[u8])] =
1530 &[(b"pk1", b"Grape"), (b"pk2", b"Watermelon")];
1531 for (k, v) in records {
1532 primary
1533 .lock()
1534 .put(
1535 None,
1536 &DatabaseEntry::from_bytes(k),
1537 &DatabaseEntry::from_bytes(v),
1538 )
1539 .unwrap();
1540 }
1541
1542 // Open secondary with allow_populate=true.
1543 let sec_db_config = DatabaseConfig::new()
1544 .with_allow_create(true)
1545 .with_sorted_duplicates(true);
1546 let sec_db =
1547 env.open_database(None, "secondary_pop", &sec_db_config).unwrap();
1548 let sec_config = SecondaryConfig::new()
1549 .with_allow_create(true)
1550 .with_allow_populate(true)
1551 .with_key_creator(Box::new(FirstByteKeyCreator));
1552 let secondary =
1553 SecondaryDatabase::open(Arc::clone(&primary), sec_db, sec_config)
1554 .unwrap();
1555
1556 // The secondary should have been populated.
1557 let sec_key_g = DatabaseEntry::from_bytes(b"G");
1558 let mut pk = DatabaseEntry::new();
1559 let mut data = DatabaseEntry::new();
1560 let status =
1561 secondary.get(None, &sec_key_g, &mut pk, &mut data).unwrap();
1562 assert_eq!(status, OperationStatus::Success);
1563 assert_eq!(data.get_data().unwrap(), b"Grape");
1564 }
1565
1566 /// Note:
1567 /// Low) — the new convenience methods on SecondaryDatabase delegate
1568 /// to the inner index DB and surface the JE-shape API for the
1569 /// secondary side.
1570 #[test]
1571 fn test_count_exists_truncate_round_trip() {
1572 let (_tmp, env) = temp_env();
1573 let primary = Arc::new(Mutex::new(open_primary(&env, "pri")));
1574 let secondary = open_secondary(Arc::clone(&primary), &env, "sec");
1575
1576 // Empty index → count == 0, no key exists.
1577 assert_eq!(secondary.count().unwrap(), 0);
1578 assert!(
1579 !secondary.exists(None, &DatabaseEntry::from_bytes(b"A")).unwrap()
1580 );
1581
1582 // Populate three primaries with distinct first-byte secondary keys.
1583 for (pk, pv) in &[
1584 (&b"pk1"[..], &b"Apple"[..]),
1585 (&b"pk2"[..], &b"Banana"[..]),
1586 (&b"pk3"[..], &b"Cherry"[..]),
1587 ] {
1588 let pk_e = DatabaseEntry::from_bytes(pk);
1589 let pv_e = DatabaseEntry::from_bytes(pv);
1590 primary.lock().put(None, &pk_e, &pv_e).unwrap();
1591 secondary.update_secondary(None, &pk_e, None, Some(&pv_e)).unwrap();
1592 }
1593
1594 assert_eq!(secondary.count().unwrap(), 3);
1595 assert!(
1596 secondary.exists(None, &DatabaseEntry::from_bytes(b"A")).unwrap()
1597 );
1598 assert!(
1599 secondary.exists(None, &DatabaseEntry::from_bytes(b"C")).unwrap()
1600 );
1601 assert!(
1602 !secondary.exists(None, &DatabaseEntry::from_bytes(b"Z")).unwrap()
1603 );
1604
1605 // Truncate clears every record and reports the pre-truncate count.
1606 let removed = secondary.truncate().unwrap();
1607 assert_eq!(removed, 3);
1608 assert_eq!(secondary.count().unwrap(), 0);
1609 assert!(
1610 !secondary.exists(None, &DatabaseEntry::from_bytes(b"A")).unwrap()
1611 );
1612 }
1613}