Skip to main content

powdb/
lib.rs

1//! # PowDB — embedded
2//!
3//! Run the PowDB engine **in-process** — no server, no socket. This is the
4//! SQLite-shaped front door to the same storage engine, indexes, WAL durability,
5//! and PowQL/SQL frontends that the `powdb-server` crate exposes over the wire.
6//! Because there is no network round-trip, single-op latency is the engine's
7//! own cost (~µs), and the database works fully offline — the foundation for
8//! local-first apps.
9//!
10//! ```no_run
11//! use powdb::{Database, QueryResult, Value};
12//!
13//! let mut db = Database::open("./data")?;
14//! db.query("type User { required name: str, age: int }")?;
15//! db.query(r#"insert User { name := "Ada", age := 36 }"#)?;
16//! match db.query("count(User)")? {
17//!     QueryResult::Scalar(Value::Int(n)) => assert_eq!(n, 1),
18//!     other => panic!("unexpected: {other:?}"),
19//! }
20//! # Ok::<(), powdb::Error>(())
21//! ```
22//!
23//! ## Panic safety
24//!
25//! The server crate is built crash-only (`panic = "abort"`): a panic exits the
26//! process and a supervisor restarts it, recovering via WAL replay. An embedded
27//! host can't have the database abort *it*, so every query here is wrapped in
28//! [`std::panic::catch_unwind`]. A caught panic **poisons the handle** — further
29//! calls return [`Error::Poisoned`] — and the handle is dropped **without** a
30//! clean checkpoint (a panic mid-mutation may have left in-memory pages torn;
31//! flushing them would persist garbage). Committed data is already durable in
32//! the WAL, so reopening the database recovers a consistent state by replay.
33//! This is the same crash-only contract, scoped to a handle instead of the
34//! process. (`catch_unwind` only catches when the final binary is built with
35//! `panic = "unwind"`, the default for applications and the official Node addon.)
36//!
37//! ## Lossless typed results
38//!
39//! Every query method here ([`Database::query`], [`Database::query_sql`],
40//! [`Database::query_readonly`], and the `*_with_params` variants) returns a
41//! [`QueryResult`] whose rows and scalar are strongly typed [`Value`]s, not
42//! strings. This is the **lossless** surface: an [`Value::Int`] keeps its full
43//! `i64` range, [`Value::Bytes`] keeps its raw bytes, and a JSON `null`
44//! ([`Value::Json`]) stays distinct from a missing/absent cell
45//! ([`Value::Empty`]). String rendering via [`Value::to_wire_string`] is a
46//! separate, deliberately lossy convenience for display and the legacy string
47//! protocol. Prefer the typed [`Value`] directly when a distinction matters.
48//!
49//! ## Parameters
50//!
51//! [`Database::query_with_params`] and
52//! [`Database::query_readonly_with_params`] bind positional `$1..$N`
53//! placeholders. Parameters are substituted as literal *tokens* before parsing,
54//! so an injection-shaped string is inert data that can never change the
55//! query's shape.
56
57use 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
67/// Render canonical PJ1 (binary JSON) bytes as canonical JSON text.
68///
69/// Re-exported so callers of the lossless typed surface (see below) can turn a
70/// [`Value::Json`] cell into JSON text without depending on `powdb-storage`
71/// directly. The Node addon uses this to decode a JSON cell into a parsed
72/// JavaScript value while still handing back the raw PJ1 bytes untouched.
73pub 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/// An error from the embedded API.
87#[derive(Debug)]
88pub enum Error {
89    /// Opening the data directory failed (I/O, permissions, corruption).
90    Open(std::io::Error),
91    /// The query failed (parse, plan, type, or execution error).
92    Query(QueryError),
93    /// The handle is poisoned: a previous call panicked. Reopen the database.
94    Poisoned,
95    /// Opening the database panicked (e.g. a corrupt heap/index header). Caught
96    /// at the boundary so it never aborts the embedded host. The data directory
97    /// is likely corrupt; restore from a backup.
98    OpenPanicked,
99    /// A caller-supplied argument was invalid (e.g. an unknown sync-mode name).
100    InvalidArgument(String),
101    /// Embedded retained-unit sync apply failed.
102    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
124/// Parse a JS-/CLI-facing sync-mode name into a [`WalSyncMode`] (case
125/// insensitive). `None` for anything other than `full` / `normal` / `off`.
126pub 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
135/// Convert typed embedded [`Value`] parameters into the query-crate
136/// [`ParamValue`]s the engine binds. Only the five scalar shapes PowQL literals
137/// can take are bindable; a binary shape ([`Value::Bytes`], [`Value::Uuid`],
138/// [`Value::Json`], [`Value::DateTime`]) has no parameter encoding, so it is
139/// rejected with an actionable [`Error::InvalidArgument`] rather than silently
140/// coerced or truncated.
141fn 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/// Database identity and format metadata required to apply retained sync units.
169///
170/// Every field comes from the primary's sync identity (see `powdb-sync`); the
171/// applier refuses a chunk whose identity does not match this replica's, so a
172/// mismatched value is rejected rather than silently corrupting state.
173#[derive(Debug, Clone)]
174pub struct SyncApplyIdentity {
175    /// The primary's 16-byte database id. Must equal this replica's.
176    pub database_id: [u8; 16],
177    /// The primary's generation counter, bumped each time it forks a new
178    /// sync lineage. Guards against applying units from a diverged primary.
179    pub primary_generation: u64,
180    /// WAL record format version the units were written with.
181    pub wal_format_version: u16,
182    /// Catalog format version the units were written with.
183    pub catalog_version: u16,
184    /// Retained-segment container format version. Must equal
185    /// [`RETAINED_SEGMENT_FORMAT_VERSION`] or the apply is rejected.
186    pub segment_format_version: u16,
187}
188
189/// One retained replication unit accepted by the embedded sync applier.
190#[derive(Debug, Clone)]
191pub struct RetainedUnitInput {
192    /// Transaction id the unit belongs to (units are applied atomically per tx).
193    pub tx_id: u64,
194    /// WAL record type discriminant for the unit's payload.
195    pub record_type: u8,
196    /// Log sequence number of the unit; units apply in ascending `lsn` order.
197    pub lsn: u64,
198    /// Raw serialized WAL record payload.
199    pub data: Vec<u8>,
200}
201
202/// Request to apply one already-pulled retained-unit chunk.
203#[derive(Debug, Clone)]
204pub struct RetainedApplyRequest {
205    /// The replica's current apply boundary; units at or below this lsn are
206    /// skipped. Must match the seeded boundary from the sync bootstrap.
207    pub since_lsn: u64,
208    /// Primary identity and format metadata the units were produced under.
209    pub identity: SyncApplyIdentity,
210    /// The contiguous retained units to apply, in ascending `lsn` order.
211    pub units: Vec<RetainedUnitInput>,
212}
213
214/// Summary returned after retained-unit chunk apply.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub struct RetainedApplyResult {
217    pub through_lsn: u64,
218    pub units_applied: usize,
219}
220
221/// An in-process PowDB database handle.
222///
223/// One handle owns one [`Engine`] over a data directory. The handle is **not**
224/// `Sync` for concurrent mutation — clone the data dir or use the server for
225/// multi-writer access. Reads ([`Database::query_readonly`]) take `&self`.
226pub struct Database {
227    // `Option` so a poisoned handle can `take()` + `forget` the engine on drop,
228    // skipping the clean checkpoint. Always `Some` until `Drop`.
229    engine: Option<Engine>,
230    poisoned: AtomicBool,
231}
232
233impl Database {
234    /// Open (or create) a database at `dir`.
235    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    /// Open a database **read-only** over a quiescent directory for snapshot
243    /// serving (a restored backup or a checkpointed replica). Nothing on disk is
244    /// ever mutated: reads work, and every mutating statement (via
245    /// [`Database::query`] / [`Database::query_sql`] / the `*_with_params`
246    /// variants) returns a terminal read-only error instead of writing. N
247    /// read-only handles across processes may serve the same directory
248    /// concurrently. A non-empty WAL is refused with an actionable error: the
249    /// directory must be recovered by a read-write open first.
250    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    /// Read-only open with an explicit per-query memory budget (bytes).
258    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    /// Open with an explicit per-query memory budget (bytes).
269    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    /// Turn a `catch_unwind`'d engine-open result into a `Database`. A panic in
284    /// the open path (e.g. a corrupt heap/index header) becomes
285    /// [`Error::OpenPanicked`] instead of unwinding past the boundary and
286    /// aborting the embedded host — the same crash-only contract the query
287    /// methods use, applied to open.
288    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    /// Run a PowQL statement.
299    pub fn query(&mut self, powql: &str) -> Result<QueryResult, Error> {
300        self.run_mut(|e| e.execute_powql(powql))
301    }
302
303    /// Run a SQL statement (lowered to PowQL by the SQL frontend).
304    pub fn query_sql(&mut self, sql: &str) -> Result<QueryResult, Error> {
305        self.run_mut(|e| e.execute_sql(sql))
306    }
307
308    /// Run a read-only PowQL statement under a shared borrow. Errors if the
309    /// statement would mutate.
310    pub fn query_readonly(&self, powql: &str) -> Result<QueryResult, Error> {
311        match self.run_ref(|e| e.execute_powql_readonly(powql)) {
312            // The read-only path signals "this statement writes" with an
313            // internal sentinel; translate it into an actionable message
314            // rather than leaking the sentinel to embedded callers.
315            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    /// Run a PowQL statement with positional `$1..$N` parameters bound from
324    /// `params`, returning the same lossless typed [`QueryResult`] as
325    /// [`Database::query`]. Parameters are substituted as literal tokens before
326    /// parsing, so untrusted input can never change the query's shape.
327    ///
328    /// Only the five scalar parameter shapes are bindable: [`Value::Int`],
329    /// [`Value::Float`], [`Value::Bool`], [`Value::Str`], and [`Value::Empty`]
330    /// (which binds PowQL `null`). Binary shapes ([`Value::Bytes`],
331    /// [`Value::Uuid`], [`Value::Json`], [`Value::DateTime`]) have no parameter
332    /// encoding and are rejected with [`Error::InvalidArgument`].
333    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    /// Read-only variant of [`Database::query_with_params`] under a shared
343    /// borrow. Errors with [`Error::InvalidArgument`] if the statement would
344    /// mutate.
345    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            // Same sentinel translation as `query_readonly`: turn the internal
353            // "this statement writes" marker into an actionable message.
354            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    /// Set the WAL durability mode (`Full` default | `Normal` | `Off`).
363    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    /// Set the WAL durability mode from a string (`"full"` | `"normal"` |
370    /// `"off"`, case insensitive) — the form the Node addon and CLIs use.
371    /// Errors on an unknown name rather than silently keeping the old mode.
372    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    /// Whether the handle has been poisoned by a caught panic.
383    pub fn is_poisoned(&self) -> bool {
384        self.poisoned.load(Ordering::Acquire)
385    }
386
387    /// Apply one retained-unit chunk to this embedded replica.
388    ///
389    /// This is the native adapter behind `@zvndev/powdb-sync`'s
390    /// `LocalReplica.applyRetainedUnits` contract. The request must start at a
391    /// trusted local apply boundary seeded by sync backup bootstrap.
392    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    /// Close the database, flushing and checkpointing. Equivalent to dropping
434    /// the handle, but explicit at the call site.
435    pub fn close(self) {
436        // Drop runs the checkpoint (or the poison path).
437    }
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                // A caught panic may have left in-memory pages torn. Skip the
496                // engine's clean checkpoint (it would persist garbage) by
497                // leaking the handle; committed data is durable in the WAL and
498                // recovered by replay on the next open. Crash-only, per-handle.
499                std::mem::forget(engine);
500            }
501            // Otherwise the engine drops normally (checkpoint: flush + WAL
502            // truncate).
503        }
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    /// The guard converts a panic into `Error::Poisoned` and flips the flag,
512    /// instead of unwinding past the boundary. This is the load-bearing
513    /// behavior the Node addon relies on to never abort its host.
514    #[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    /// A poisoned handle rejects further queries without touching the engine,
534    /// and a poisoned drop skips the checkpoint yet loses no committed data
535    /// (WAL replay recovers it on reopen).
536    #[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(); // committed (Full mode fsyncs)
544                                                       // Simulate a caught panic by poisoning the handle directly.
545            db.poisoned.store(true, Ordering::Release);
546            assert!(db.is_poisoned());
547            assert!(matches!(db.query("count(T)"), Err(Error::Poisoned)));
548            // Drop here takes the poison path: forget the engine, no checkpoint.
549        }
550        // Reopen: the committed row is recovered from the WAL.
551        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        // A write still commits under Normal durability.
613        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    /// A corrupt data directory must surface as an `Err`, never unwind/abort the
712    /// embedded host. A trashed heap header panics deep in the open path; the
713    /// facade's `catch_unwind` must convert that into `Error::OpenPanicked`.
714    /// (Only meaningful under `panic = "unwind"`, which the Node addon and the
715    /// test profile use.)
716    #[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            // Clean drop checkpoints: the heap file is now the durable state.
725        }
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    /// The typed surface returns real `Value`s, not strings: an `i64` beyond
742    /// `Number.MAX_SAFE_INTEGER` survives exactly, bytes survive, and a JSON
743    /// `null` stays distinct from a missing (Empty) cell.
744    #[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        // 9_007_199_254_740_993 = 2^53 + 1, unrepresentable as an f64/JS number.
752        db.query("insert Doc { id := 1, big := 9007199254740993, body := \"null\" }")
753            .unwrap();
754        // Row 2 omits the optional `body`, leaving it Empty (missing), and its
755        // big value is a distinct large int.
756        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                // Full i64 range preserved, not rounded through f64.
763                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                // Whole JSON column carrying `null` is a typed Json value,
766                // distinct from the missing/Empty cell in row 2.
767                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        // Bind a str and an int as positional params.
789        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            // Clean drop checkpoints: quiescent (WAL-clean) directory.
871        }
872
873        let mut db = Database::open_read_only(&dir).unwrap();
874        // Reads work through both query() and query_readonly().
875        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        // A mutation via query() returns a terminal read-only error, not a panic.
889        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            // Leave the WAL non-empty by forgetting the catalog (a crash).
907            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}