1use std::panic::{catch_unwind, AssertUnwindSafe};
58use std::path::Path;
59use std::sync::atomic::{AtomicBool, Ordering};
60
61pub use powdb_query::ast::ParamValue;
62pub use powdb_query::executor::{Engine, WalSyncMode};
63pub use powdb_query::result::{QueryError, QueryResult};
64pub use powdb_storage::types::{TypeId, Value};
65pub use powdb_sync::RETAINED_SEGMENT_FORMAT_VERSION;
66
67pub use powdb_storage::pj1::pj1_to_text;
74
75fn archive_wal_records_if_sync_enabled(
76 data_dir: &Path,
77 records: &[powdb_storage::wal::WalRecord],
78) -> std::io::Result<()> {
79 match powdb_sync::read_identity(data_dir) {
80 Ok(identity) => powdb_sync::archive_wal_records_for_identity(data_dir, identity, records),
81 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
82 Err(err) => Err(err),
83 }
84}
85
86#[derive(Debug)]
88pub enum Error {
89 Open(std::io::Error),
91 Query(QueryError),
93 Poisoned,
95 OpenPanicked,
99 InvalidArgument(String),
101 Sync(std::io::Error),
103}
104
105impl std::fmt::Display for Error {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 match self {
108 Error::Open(e) => write!(f, "failed to open database: {e}"),
109 Error::Query(e) => write!(f, "query failed: {e}"),
110 Error::Poisoned => write!(
111 f,
112 "database handle is poisoned (a previous call panicked); reopen the database"
113 ),
114 Error::OpenPanicked => write!(
115 f,
116 "opening the database panicked (data directory may be corrupt); restore from a backup"
117 ),
118 Error::InvalidArgument(msg) => write!(f, "{msg}"),
119 Error::Sync(e) => write!(f, "sync apply failed: {e}"),
120 }
121 }
122}
123
124pub fn parse_sync_mode(mode: &str) -> Option<WalSyncMode> {
127 match mode.to_ascii_lowercase().as_str() {
128 "full" => Some(WalSyncMode::Full),
129 "normal" => Some(WalSyncMode::Normal),
130 "off" => Some(WalSyncMode::Off),
131 _ => None,
132 }
133}
134
135fn values_to_params(params: &[Value]) -> Result<Vec<ParamValue>, Error> {
142 params
143 .iter()
144 .map(|value| match value {
145 Value::Int(v) => Ok(ParamValue::Int(*v)),
146 Value::Float(v) => Ok(ParamValue::Float(*v)),
147 Value::Bool(v) => Ok(ParamValue::Bool(*v)),
148 Value::Str(v) => Ok(ParamValue::Str(v.clone())),
149 Value::Empty => Ok(ParamValue::Null),
150 other => Err(Error::InvalidArgument(format!(
151 "cannot bind a {:?} value as a query parameter; supported parameter types are int, float, bool, str, and null",
152 other.type_id()
153 ))),
154 })
155 .collect()
156}
157
158impl std::error::Error for Error {
159 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
160 match self {
161 Error::Open(e) => Some(e),
162 Error::Sync(e) => Some(e),
163 _ => None,
164 }
165 }
166}
167
168#[derive(Debug, Clone)]
174pub struct SyncApplyIdentity {
175 pub database_id: [u8; 16],
177 pub primary_generation: u64,
180 pub wal_format_version: u16,
182 pub catalog_version: u16,
184 pub segment_format_version: u16,
187}
188
189#[derive(Debug, Clone)]
191pub struct RetainedUnitInput {
192 pub tx_id: u64,
194 pub record_type: u8,
196 pub lsn: u64,
198 pub data: Vec<u8>,
200}
201
202#[derive(Debug, Clone)]
204pub struct RetainedApplyRequest {
205 pub since_lsn: u64,
208 pub identity: SyncApplyIdentity,
210 pub units: Vec<RetainedUnitInput>,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub struct RetainedApplyResult {
217 pub through_lsn: u64,
218 pub units_applied: usize,
219}
220
221pub struct Database {
227 engine: Option<Engine>,
230 poisoned: AtomicBool,
231}
232
233impl Database {
234 pub fn open(dir: impl AsRef<Path>) -> Result<Self, Error> {
236 let dir = dir.as_ref();
237 Self::wrap_open(catch_unwind(AssertUnwindSafe(|| {
238 Engine::new_with_wal_archive(dir, archive_wal_records_if_sync_enabled)
239 })))
240 }
241
242 pub fn open_read_only(dir: impl AsRef<Path>) -> Result<Self, Error> {
251 let dir = dir.as_ref();
252 Self::wrap_open(catch_unwind(AssertUnwindSafe(|| {
253 Engine::open_read_only(dir)
254 })))
255 }
256
257 pub fn open_read_only_with_memory_limit(
259 dir: impl AsRef<Path>,
260 limit_bytes: usize,
261 ) -> Result<Self, Error> {
262 let dir = dir.as_ref();
263 Self::wrap_open(catch_unwind(AssertUnwindSafe(|| {
264 Engine::open_read_only_with_memory_limit(dir, limit_bytes)
265 })))
266 }
267
268 pub fn open_with_memory_limit(
270 dir: impl AsRef<Path>,
271 limit_bytes: usize,
272 ) -> Result<Self, Error> {
273 let dir = dir.as_ref();
274 Self::wrap_open(catch_unwind(AssertUnwindSafe(|| {
275 Engine::with_memory_limit_and_wal_archive(
276 dir,
277 limit_bytes,
278 archive_wal_records_if_sync_enabled,
279 )
280 })))
281 }
282
283 fn wrap_open(result: std::thread::Result<std::io::Result<Engine>>) -> Result<Self, Error> {
289 let engine = result
290 .map_err(|_| Error::OpenPanicked)?
291 .map_err(Error::Open)?;
292 Ok(Self {
293 engine: Some(engine),
294 poisoned: AtomicBool::new(false),
295 })
296 }
297
298 pub fn query(&mut self, powql: &str) -> Result<QueryResult, Error> {
300 self.run_mut(|e| e.execute_powql(powql))
301 }
302
303 pub fn query_sql(&mut self, sql: &str) -> Result<QueryResult, Error> {
305 self.run_mut(|e| e.execute_sql(sql))
306 }
307
308 pub fn query_readonly(&self, powql: &str) -> Result<QueryResult, Error> {
311 match self.run_ref(|e| e.execute_powql_readonly(powql)) {
312 Err(Error::Query(QueryError::ReadonlyNeedsWrite)) => Err(Error::InvalidArgument(
316 "statement would mutate the database; use query() instead of query_readonly()"
317 .to_string(),
318 )),
319 other => other,
320 }
321 }
322
323 pub fn query_with_params(
334 &mut self,
335 powql: &str,
336 params: &[Value],
337 ) -> Result<QueryResult, Error> {
338 let bound = values_to_params(params)?;
339 self.run_mut(|e| e.execute_powql_with_params(powql, &bound))
340 }
341
342 pub fn query_readonly_with_params(
346 &self,
347 powql: &str,
348 params: &[Value],
349 ) -> Result<QueryResult, Error> {
350 let bound = values_to_params(params)?;
351 match self.run_ref(|e| e.execute_powql_readonly_with_params(powql, &bound)) {
352 Err(Error::Query(QueryError::ReadonlyNeedsWrite)) => Err(Error::InvalidArgument(
355 "statement would mutate the database; use query_with_params() instead of query_readonly_with_params()"
356 .to_string(),
357 )),
358 other => other,
359 }
360 }
361
362 pub fn set_sync_mode(&mut self, mode: WalSyncMode) {
364 if let Some(engine) = self.engine.as_mut() {
365 engine.set_wal_sync_mode(mode);
366 }
367 }
368
369 pub fn set_sync_mode_str(&mut self, mode: &str) -> Result<(), Error> {
373 let parsed = parse_sync_mode(mode).ok_or_else(|| {
374 Error::InvalidArgument(format!(
375 "unknown sync mode {mode:?}; expected \"full\", \"normal\", or \"off\""
376 ))
377 })?;
378 self.set_sync_mode(parsed);
379 Ok(())
380 }
381
382 pub fn is_poisoned(&self) -> bool {
384 self.poisoned.load(Ordering::Acquire)
385 }
386
387 pub fn apply_retained_units(
393 &mut self,
394 request: RetainedApplyRequest,
395 ) -> Result<RetainedApplyResult, Error> {
396 if request.identity.segment_format_version != RETAINED_SEGMENT_FORMAT_VERSION {
397 return Err(Error::InvalidArgument(format!(
398 "unsupported retained segment format {}; expected {}",
399 request.identity.segment_format_version, RETAINED_SEGMENT_FORMAT_VERSION
400 )));
401 }
402 let identity = powdb_sync::SegmentIdentity {
403 database_id: request.identity.database_id,
404 primary_generation: request.identity.primary_generation,
405 wal_format_version: request.identity.wal_format_version,
406 catalog_version: request.identity.catalog_version,
407 };
408 let units = request
409 .units
410 .into_iter()
411 .map(|unit| powdb_sync::RetainedUnit {
412 tx_id: unit.tx_id,
413 record_type: unit.record_type,
414 lsn: unit.lsn,
415 data: unit.data,
416 })
417 .collect::<Vec<_>>();
418
419 self.run_sync_mut(|engine| {
420 let summary = powdb_sync::apply_retained_units_chunk(
421 engine.catalog_mut(),
422 identity,
423 request.since_lsn,
424 &units,
425 )?;
426 Ok(RetainedApplyResult {
427 through_lsn: summary.through_lsn,
428 units_applied: summary.units_applied,
429 })
430 })
431 }
432
433 pub fn close(self) {
436 }
438
439 fn run_mut(
440 &mut self,
441 f: impl FnOnce(&mut Engine) -> Result<QueryResult, QueryError>,
442 ) -> Result<QueryResult, Error> {
443 if self.poisoned.load(Ordering::Acquire) {
444 return Err(Error::Poisoned);
445 }
446 let engine = self.engine.as_mut().expect("engine present until drop");
447 match catch_unwind(AssertUnwindSafe(|| f(engine))) {
448 Ok(inner) => inner.map_err(Error::Query),
449 Err(_) => {
450 self.poisoned.store(true, Ordering::Release);
451 Err(Error::Poisoned)
452 }
453 }
454 }
455
456 fn run_ref(
457 &self,
458 f: impl FnOnce(&Engine) -> Result<QueryResult, QueryError>,
459 ) -> Result<QueryResult, Error> {
460 if self.poisoned.load(Ordering::Acquire) {
461 return Err(Error::Poisoned);
462 }
463 let engine = self.engine.as_ref().expect("engine present until drop");
464 match catch_unwind(AssertUnwindSafe(|| f(engine))) {
465 Ok(inner) => inner.map_err(Error::Query),
466 Err(_) => {
467 self.poisoned.store(true, Ordering::Release);
468 Err(Error::Poisoned)
469 }
470 }
471 }
472
473 fn run_sync_mut<T>(
474 &mut self,
475 f: impl FnOnce(&mut Engine) -> std::io::Result<T>,
476 ) -> Result<T, Error> {
477 if self.poisoned.load(Ordering::Acquire) {
478 return Err(Error::Poisoned);
479 }
480 let engine = self.engine.as_mut().expect("engine present until drop");
481 match catch_unwind(AssertUnwindSafe(|| f(engine))) {
482 Ok(inner) => inner.map_err(Error::Sync),
483 Err(_) => {
484 self.poisoned.store(true, Ordering::Release);
485 Err(Error::Poisoned)
486 }
487 }
488 }
489}
490
491impl Drop for Database {
492 fn drop(&mut self) {
493 if let Some(engine) = self.engine.take() {
494 if self.poisoned.load(Ordering::Acquire) {
495 std::mem::forget(engine);
500 }
501 }
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
515 fn caught_panic_poisons_and_returns_error() {
516 let poisoned = AtomicBool::new(false);
517 let result: Result<(), Error> = (|| {
518 if poisoned.load(Ordering::Acquire) {
519 return Err(Error::Poisoned);
520 }
521 match catch_unwind(AssertUnwindSafe(|| panic!("boom"))) {
522 Ok(()) => Ok(()),
523 Err(_) => {
524 poisoned.store(true, Ordering::Release);
525 Err(Error::Poisoned)
526 }
527 }
528 })();
529 assert!(matches!(result, Err(Error::Poisoned)));
530 assert!(poisoned.load(Ordering::Acquire));
531 }
532
533 #[test]
537 fn poisoned_handle_rejects_then_reopen_recovers() {
538 let dir = std::env::temp_dir().join(format!("powdb_facade_poison_{}", std::process::id()));
539 let _ = std::fs::remove_dir_all(&dir);
540 {
541 let mut db = Database::open(&dir).unwrap();
542 db.query("type T { required id: int }").unwrap();
543 db.query("insert T { id := 1 }").unwrap(); db.poisoned.store(true, Ordering::Release);
546 assert!(db.is_poisoned());
547 assert!(matches!(db.query("count(T)"), Err(Error::Poisoned)));
548 }
550 let mut db = Database::open(&dir).unwrap();
552 match db.query("count(T)").unwrap() {
553 QueryResult::Scalar(Value::Int(n)) => assert_eq!(n, 1),
554 other => panic!("expected 1 row after poisoned-drop reopen, got {other:?}"),
555 }
556 let _ = std::fs::remove_dir_all(&dir);
557 }
558
559 #[test]
560 fn query_error_displays_human_message_not_debug() {
561 let dir = std::env::temp_dir().join(format!("powdb_facade_disp_{}", std::process::id()));
562 let _ = std::fs::remove_dir_all(&dir);
563 let mut db = Database::open(&dir).unwrap();
564 let err = db.query("Missing filter .x > 1").unwrap_err();
565 let msg = err.to_string();
566 assert!(
567 msg.contains("table 'Missing' not found"),
568 "expected human Display, got {msg:?}"
569 );
570 assert!(!msg.contains("TableNotFound"), "leaked Debug: {msg:?}");
571 let _ = std::fs::remove_dir_all(&dir);
572 }
573
574 #[test]
575 fn query_readonly_rejects_mutation_with_actionable_message() {
576 let dir = std::env::temp_dir().join(format!("powdb_facade_ro_{}", std::process::id()));
577 let _ = std::fs::remove_dir_all(&dir);
578 let mut db = Database::open(&dir).unwrap();
579 db.query("type T { required id: int }").unwrap();
580 let err = db.query_readonly("insert T { id := 1 }").unwrap_err();
581 let msg = err.to_string();
582 assert!(
583 msg.contains("use query()"),
584 "expected actionable hint, got {msg:?}"
585 );
586 assert!(
587 !msg.contains("__POWDB_READONLY_NEEDS_WRITE__"),
588 "internal sentinel leaked to caller: {msg:?}"
589 );
590 assert!(matches!(err, Error::InvalidArgument(_)));
591 let _ = std::fs::remove_dir_all(&dir);
592 }
593
594 #[test]
595 fn parse_sync_mode_is_case_insensitive_and_rejects_unknown() {
596 assert!(matches!(parse_sync_mode("full"), Some(WalSyncMode::Full)));
597 assert!(matches!(
598 parse_sync_mode("Normal"),
599 Some(WalSyncMode::Normal)
600 ));
601 assert!(matches!(parse_sync_mode("OFF"), Some(WalSyncMode::Off)));
602 assert!(parse_sync_mode("bogus").is_none());
603 }
604
605 #[test]
606 fn set_sync_mode_str_sets_known_and_errors_on_unknown() {
607 let dir = std::env::temp_dir().join(format!("powdb_syncstr_{}", std::process::id()));
608 let _ = std::fs::remove_dir_all(&dir);
609 let mut db = Database::open(&dir).unwrap();
610 db.query("type T { required id: int }").unwrap();
611 db.set_sync_mode_str("normal").unwrap();
612 db.query("insert T { id := 1 }").unwrap();
614 assert!(db.set_sync_mode_str("bogus").is_err());
615 let _ = std::fs::remove_dir_all(&dir);
616 }
617
618 #[test]
619 fn apply_retained_units_noop_uses_seeded_sync_boundary() {
620 let dir = std::env::temp_dir().join(format!("powdb_facade_apply_{}", std::process::id()));
621 let _ = std::fs::remove_dir_all(&dir);
622 let database_id = [7u8; 16];
623 seed_apply_boundary(&dir, database_id, 1, 0);
624
625 let mut db = Database::open(&dir).unwrap();
626 let result = db
627 .apply_retained_units(RetainedApplyRequest {
628 since_lsn: 0,
629 identity: SyncApplyIdentity {
630 database_id,
631 primary_generation: 1,
632 wal_format_version: powdb_storage::wal::WAL_FORMAT_VERSION,
633 catalog_version: powdb_storage::catalog::CATALOG_VERSION,
634 segment_format_version: RETAINED_SEGMENT_FORMAT_VERSION,
635 },
636 units: Vec::new(),
637 })
638 .unwrap();
639 assert_eq!(
640 result,
641 RetainedApplyResult {
642 through_lsn: 0,
643 units_applied: 0
644 }
645 );
646 let _ = std::fs::remove_dir_all(&dir);
647 }
648
649 #[test]
650 fn apply_retained_units_rejects_wrong_segment_format() {
651 let dir =
652 std::env::temp_dir().join(format!("powdb_facade_apply_badfmt_{}", std::process::id()));
653 let _ = std::fs::remove_dir_all(&dir);
654 let mut db = Database::open(&dir).unwrap();
655 let err = db
656 .apply_retained_units(RetainedApplyRequest {
657 since_lsn: 0,
658 identity: SyncApplyIdentity {
659 database_id: [7u8; 16],
660 primary_generation: 1,
661 wal_format_version: powdb_storage::wal::WAL_FORMAT_VERSION,
662 catalog_version: powdb_storage::catalog::CATALOG_VERSION,
663 segment_format_version: RETAINED_SEGMENT_FORMAT_VERSION + 1,
664 },
665 units: Vec::new(),
666 })
667 .unwrap_err();
668 assert!(matches!(err, Error::InvalidArgument(_)));
669 let _ = std::fs::remove_dir_all(&dir);
670 }
671
672 #[test]
673 fn open_archives_pending_sync_wal_before_recovery_truncates() {
674 let dir =
675 std::env::temp_dir().join(format!("powdb_facade_sync_recovery_{}", std::process::id()));
676 let _ = std::fs::remove_dir_all(&dir);
677 let identity = {
678 let mut catalog = powdb_storage::catalog::Catalog::create(&dir).unwrap();
679 catalog
680 .create_table(powdb_storage::types::Schema {
681 table_name: "T".into(),
682 columns: vec![powdb_storage::types::ColumnDef {
683 name: "id".into(),
684 type_id: powdb_storage::types::TypeId::Int,
685 required: true,
686 position: 0,
687 }],
688 })
689 .unwrap();
690 catalog.insert("T", &vec![Value::Int(1)]).unwrap();
691 catalog.sync_wal().unwrap();
692 powdb_sync::open_or_create_identity(&dir).unwrap()
693 };
694
695 let db = Database::open(&dir).unwrap();
696 drop(db);
697 let units = powdb_sync::read_units_since(
698 &powdb_sync::retained_segments_dir(&dir),
699 identity.segment_identity(),
700 0,
701 100,
702 )
703 .unwrap();
704 assert!(
705 !units.is_empty(),
706 "embedded facade open must preserve replayed sync WAL"
707 );
708 let _ = std::fs::remove_dir_all(&dir);
709 }
710
711 #[test]
717 fn open_with_corrupt_heap_returns_error_not_panic() {
718 let dir = std::env::temp_dir().join(format!("powdb_corrupt_heap_{}", std::process::id()));
719 let _ = std::fs::remove_dir_all(&dir);
720 {
721 let mut db = Database::open(&dir).unwrap();
722 db.query("type T { required id: int }").unwrap();
723 db.query("insert T { id := 1 }").unwrap();
724 }
726 let heap = dir.join("T.heap");
727 let mut bytes = std::fs::read(&heap).unwrap();
728 for b in bytes.iter_mut().take(20) {
729 *b = 0xFF;
730 }
731 std::fs::write(&heap, &bytes).unwrap();
732
733 let result = Database::open(&dir);
734 let _ = std::fs::remove_dir_all(&dir);
735 assert!(
736 result.is_err(),
737 "corrupt heap must return Err, not panic/abort the host"
738 );
739 }
740
741 #[test]
745 fn query_returns_lossless_typed_values() {
746 let dir = std::env::temp_dir().join(format!("powdb_facade_typed_{}", std::process::id()));
747 let _ = std::fs::remove_dir_all(&dir);
748 let mut db = Database::open(&dir).unwrap();
749 db.query("type Doc { required id: int, big: int, body: json }")
750 .unwrap();
751 db.query("insert Doc { id := 1, big := 9007199254740993, body := \"null\" }")
753 .unwrap();
754 db.query("insert Doc { id := 2, big := 9223372036854775807 }")
757 .unwrap();
758
759 match db.query("Doc { id, big, body }").unwrap() {
760 QueryResult::Rows { rows, .. } => {
761 assert_eq!(rows.len(), 2);
762 assert_eq!(rows[0][1], Value::Int(9_007_199_254_740_993));
764 assert_eq!(rows[1][1], Value::Int(9_223_372_036_854_775_807));
765 assert!(matches!(rows[0][2], Value::Json(_)));
768 assert_eq!(rows[1][2], Value::Empty);
769 assert_ne!(rows[0][2], rows[1][2]);
770 }
771 other => panic!("expected rows, got {other:?}"),
772 }
773 let _ = std::fs::remove_dir_all(&dir);
774 }
775
776 #[test]
777 fn query_with_params_binds_positional_values() {
778 let dir = std::env::temp_dir().join(format!("powdb_facade_params_{}", std::process::id()));
779 let _ = std::fs::remove_dir_all(&dir);
780 let mut db = Database::open(&dir).unwrap();
781 db.query("type User { required name: str, age: int }")
782 .unwrap();
783 db.query(r#"insert User { name := "Ada", age := 36 }"#)
784 .unwrap();
785 db.query(r#"insert User { name := "Bo", age := 20 }"#)
786 .unwrap();
787
788 match db
790 .query_with_params(
791 "User filter .name = $1 and .age > $2 { .name, .age }",
792 &[Value::Str("Ada".into()), Value::Int(30)],
793 )
794 .unwrap()
795 {
796 QueryResult::Rows { rows, .. } => {
797 assert_eq!(rows.len(), 1);
798 assert_eq!(rows[0][0], Value::Str("Ada".into()));
799 assert_eq!(rows[0][1], Value::Int(36));
800 }
801 other => panic!("expected rows, got {other:?}"),
802 }
803 let _ = std::fs::remove_dir_all(&dir);
804 }
805
806 #[test]
807 fn query_with_params_rejects_unbindable_shapes() {
808 let dir =
809 std::env::temp_dir().join(format!("powdb_facade_badparam_{}", std::process::id()));
810 let _ = std::fs::remove_dir_all(&dir);
811 let mut db = Database::open(&dir).unwrap();
812 db.query("type T { required id: int }").unwrap();
813 let err = db
814 .query_with_params("T filter .id = $1 { .id }", &[Value::Bytes(vec![1, 2, 3])])
815 .unwrap_err();
816 assert!(matches!(err, Error::InvalidArgument(_)));
817 assert!(
818 err.to_string().contains("cannot bind"),
819 "expected actionable message, got {err}"
820 );
821 let _ = std::fs::remove_dir_all(&dir);
822 }
823
824 #[test]
825 fn query_readonly_with_params_rejects_mutation() {
826 let dir = std::env::temp_dir().join(format!("powdb_facade_roparam_{}", std::process::id()));
827 let _ = std::fs::remove_dir_all(&dir);
828 let mut db = Database::open(&dir).unwrap();
829 db.query("type T { required id: int }").unwrap();
830 let err = db
831 .query_readonly_with_params("insert T { id := $1 }", &[Value::Int(1)])
832 .unwrap_err();
833 assert!(matches!(err, Error::InvalidArgument(_)));
834 assert!(
835 !err.to_string().contains("__POWDB_READONLY_NEEDS_WRITE__"),
836 "internal sentinel leaked: {err}"
837 );
838 let _ = std::fs::remove_dir_all(&dir);
839 }
840
841 #[test]
842 fn query_readonly_with_params_reads_under_shared_borrow() {
843 let dir = std::env::temp_dir().join(format!("powdb_facade_rords_{}", std::process::id()));
844 let _ = std::fs::remove_dir_all(&dir);
845 let mut db = Database::open(&dir).unwrap();
846 db.query("type T { required id: int }").unwrap();
847 db.query("insert T { id := 7 }").unwrap();
848 match db
849 .query_readonly_with_params("T filter .id = $1 { .id }", &[Value::Int(7)])
850 .unwrap()
851 {
852 QueryResult::Rows { rows, .. } => {
853 assert_eq!(rows, vec![vec![Value::Int(7)]]);
854 }
855 other => panic!("expected rows, got {other:?}"),
856 }
857 let _ = std::fs::remove_dir_all(&dir);
858 }
859
860 #[test]
861 fn open_read_only_serves_reads_and_rejects_mutations() {
862 let dir = std::env::temp_dir().join(format!("powdb_facade_ro_open_{}", std::process::id()));
863 let _ = std::fs::remove_dir_all(&dir);
864 {
865 let mut db = Database::open(&dir).unwrap();
866 db.query("type User { required name: str, age: int }")
867 .unwrap();
868 db.query(r#"insert User { name := "Ada", age := 36 }"#)
869 .unwrap();
870 }
872
873 let mut db = Database::open_read_only(&dir).unwrap();
874 match db.query("count(User)").unwrap() {
876 QueryResult::Scalar(Value::Int(n)) => assert_eq!(n, 1),
877 other => panic!("expected scalar 1, got {other:?}"),
878 }
879 match db
880 .query_readonly("User filter .age = 36 { .name }")
881 .unwrap()
882 {
883 QueryResult::Rows { rows, .. } => {
884 assert_eq!(rows[0][0], Value::Str("Ada".into()))
885 }
886 other => panic!("expected rows, got {other:?}"),
887 }
888 let err = db
890 .query(r#"insert User { name := "Bo", age := 20 }"#)
891 .unwrap_err();
892 assert!(
893 err.to_string().contains("readonly mode"),
894 "expected a read-only error, got {err}"
895 );
896 assert!(!err.to_string().contains("__POWDB_READONLY_NEEDS_WRITE__"));
897 let _ = std::fs::remove_dir_all(&dir);
898 }
899
900 #[test]
901 fn open_read_only_refuses_non_empty_wal() {
902 let dir =
903 std::env::temp_dir().join(format!("powdb_facade_ro_dirtywal_{}", std::process::id()));
904 let _ = std::fs::remove_dir_all(&dir);
905 {
906 let mut catalog = powdb_storage::catalog::Catalog::create(&dir).unwrap();
908 catalog
909 .create_table(powdb_storage::types::Schema {
910 table_name: "T".into(),
911 columns: vec![powdb_storage::types::ColumnDef {
912 name: "id".into(),
913 type_id: powdb_storage::types::TypeId::Int,
914 required: true,
915 position: 0,
916 }],
917 })
918 .unwrap();
919 catalog.insert("T", &vec![Value::Int(1)]).unwrap();
920 catalog.sync_wal().unwrap();
921 std::mem::forget(catalog);
922 }
923 let err = match Database::open_read_only(&dir) {
924 Ok(_) => panic!("read-only open must refuse a non-empty WAL"),
925 Err(err) => err,
926 };
927 assert!(
928 err.to_string().contains("WAL is not empty"),
929 "expected WAL-not-empty refusal, got {err}"
930 );
931 let _ = std::fs::remove_dir_all(&dir);
932 }
933
934 fn seed_apply_boundary(
935 dir: &std::path::Path,
936 database_id: [u8; 16],
937 generation: u64,
938 lsn: u64,
939 ) {
940 let sync_dir = dir.join(".powdb-sync");
941 std::fs::create_dir_all(&sync_dir).unwrap();
942 let database_id_hex = encode_hex_16(database_id);
943 std::fs::write(
944 sync_dir.join("identity.json"),
945 format!(
946 r#"{{"format_version":1,"database_id":"{database_id_hex}","primary_generation":{generation},"created_unix_secs":1}}"#
947 ),
948 )
949 .unwrap();
950 std::fs::write(
951 sync_dir.join("apply-state.json"),
952 format!(
953 r#"{{"format_version":1,"database_id":{},"primary_generation":{generation},"wal_format_version":{},"catalog_version":{},"from_lsn":{lsn},"through_lsn":{lsn},"applied_lsn":{lsn},"status":"complete","started_unix_secs":1,"updated_unix_secs":1}}"#,
954 json_u8_array(database_id),
955 powdb_storage::wal::WAL_FORMAT_VERSION,
956 powdb_storage::catalog::CATALOG_VERSION,
957 ),
958 )
959 .unwrap();
960 }
961
962 fn encode_hex_16(bytes: [u8; 16]) -> String {
963 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
964 }
965
966 fn json_u8_array(bytes: [u8; 16]) -> String {
967 let body = bytes
968 .iter()
969 .map(u8::to_string)
970 .collect::<Vec<_>>()
971 .join(",");
972 format!("[{body}]")
973 }
974}