Skip to main content

reddb_server/
rpc_stdio.rs

1//! JSON-RPC 2.0 line-delimited stdio mode for the `red` binary.
2//!
3//! See `PLAN_DRIVERS.md` for the protocol spec. This module is the
4//! sole server-side implementation of the protocol — drivers in
5//! every language target this contract.
6//!
7//! Loop:
8//!   1. Read a line from stdin (UTF-8, terminated by `\n`).
9//!   2. Parse it as a JSON-RPC 2.0 request envelope.
10//!   3. Dispatch on `method` to the runtime.
11//!   4. Serialize the response as a single line on stdout, flush.
12//!   5. Repeat until EOF or `close` method received.
13//!
14//! Errors do not crash the loop. Panics inside a method handler are
15//! caught and reported as `INTERNAL_ERROR` so a buggy query cannot
16//! kill the daemon.
17
18use std::io::{BufRead, BufReader, Stdin, Write};
19use std::panic::AssertUnwindSafe;
20
21use tokio::sync::Mutex as AsyncMutex;
22
23use crate::application::entity::{CreateRowInput, CreateRowsBatchInput};
24use crate::application::ports::RuntimeEntityPort;
25use crate::json::{self as json, Value};
26use crate::runtime::{RedDBRuntime, RuntimeQueryResult};
27use crate::storage::query::unified::UnifiedRecord;
28use crate::storage::schema::Value as SchemaValue;
29use reddb_client_connector::RedDBClient;
30
31/// Which backend the stdio loop is wrapping.
32///
33/// `Local` = the in-process engine (embedded). `Remote` = a tonic client
34/// to a standalone `red server` talking gRPC. The remote variant is
35/// boxed because `RedDBClient` + a `tokio::Runtime` reference is ~248
36/// bytes against `Local`'s ~8 bytes (clippy::large_enum_variant).
37///
38/// The mutex uses `tokio::sync::Mutex` instead of `std::sync::Mutex`
39/// because `dispatch_method_remote` holds the guard across `.await`
40/// points inside `tokio_rt.block_on(...)` — holding a sync mutex
41/// across an await would be a correctness bug in more complex
42/// runtimes.
43enum Backend<'a> {
44    Local(&'a RedDBRuntime),
45    Remote(Box<RemoteBackend<'a>>),
46}
47
48struct RemoteBackend<'a> {
49    client: AsyncMutex<RedDBClient>,
50    tokio_rt: &'a tokio::runtime::Runtime,
51}
52
53/// Protocol version reported by the `version` method.
54pub const PROTOCOL_VERSION: &str = "1.0";
55const STDIO_BULK_INSERT_CHUNK_ROWS: usize = 500;
56
57/// Stable error codes. Drivers map these to idiomatic exceptions.
58pub mod error_code {
59    pub const PARSE_ERROR: &str = "PARSE_ERROR";
60    pub const INVALID_REQUEST: &str = "INVALID_REQUEST";
61    pub const INVALID_PARAMS: &str = "INVALID_PARAMS";
62    pub const QUERY_ERROR: &str = "QUERY_ERROR";
63    pub const NOT_FOUND: &str = "NOT_FOUND";
64    pub const INTERNAL_ERROR: &str = "INTERNAL_ERROR";
65    /// `tx.begin` was called while a transaction was already open in the
66    /// same session.
67    pub const TX_ALREADY_OPEN: &str = "TX_ALREADY_OPEN";
68    /// `tx.commit` / `tx.rollback` was called without a matching
69    /// `tx.begin`.
70    pub const NO_TX_OPEN: &str = "NO_TX_OPEN";
71    /// A buffered statement failed during `tx.commit` replay. The error
72    /// message carries the index of the failing op and the number of
73    /// operations that successfully applied before the failure.
74    pub const TX_REPLAY_FAILED: &str = "TX_REPLAY_FAILED";
75    /// Transactions over the remote gRPC proxy are not supported yet.
76    pub const TX_NOT_SUPPORTED_REMOTE: &str = "TX_NOT_SUPPORTED_REMOTE";
77    /// `query.next` / `query.close` referenced an unknown cursor id.
78    /// Either the cursor was never opened, already closed, or was
79    /// automatically dropped when its rows were exhausted.
80    pub const CURSOR_NOT_FOUND: &str = "CURSOR_NOT_FOUND";
81    /// Too many concurrent cursors open in a single session.
82    pub const CURSOR_LIMIT_EXCEEDED: &str = "CURSOR_LIMIT_EXCEEDED";
83}
84
85/// Maximum number of cursors a single stdio session may hold open
86/// simultaneously. Serves as a memory-pressure guard against runaway
87/// clients that `query.open` without ever closing.
88pub(crate) const MAX_CURSORS_PER_SESSION: usize = 64;
89/// Default batch size for `query.next` when the client does not specify
90/// one explicitly. Tuned for small-to-medium rows; large-row clients
91/// should set a smaller value.
92pub(crate) const DEFAULT_CURSOR_BATCH_SIZE: usize = 100;
93/// Hard upper bound on `query.next` batch size. Prevents a single call
94/// from stalling the stdio loop with a multi-megabyte line.
95pub(crate) const MAX_CURSOR_BATCH_SIZE: usize = 10_000;
96
97// ---------------------------------------------------------------------------
98// Session state (transaction buffer)
99// ---------------------------------------------------------------------------
100//
101// Transactions in the stdio protocol are scoped to a single connection —
102// one process = one session = at most one open transaction. The state
103// lives in the stack of `run_backend` so nothing leaks between
104// connections, and there is no cross-session visibility of buffered
105// writes.
106//
107// Isolation model: `read_committed_deferred`. Reads inside a transaction
108// observe the latest *committed* state; they do **not** see writes the
109// same session has buffered via `insert` / `delete` / `bulk_insert`.
110// Atomicity is best-effort — a global commit lock serializes replays, but
111// auto-committed writes from other sessions may interleave between
112// commits. Strict atomicity requires funnelling every write through a
113// single session.
114
115/// Per-connection session that tracks the currently open transaction
116/// and any active streaming cursors.
117// A server-side prepared statement bound to this session.
118// When parameter_count == 0, shape == the exact plan (no substitution needed).
119struct StdioPreparedStatement {
120    shape: crate::storage::query::ast::QueryExpr,
121    parameter_count: usize,
122}
123
124pub(crate) struct Session {
125    next_tx_id: u64,
126    current_tx: Option<OpenTx>,
127    next_cursor_id: u64,
128    cursors: std::collections::HashMap<u64, Cursor>,
129    /// Monotone counter for prepared statement IDs within this session.
130    next_prepared_id: u64,
131    /// Active prepared statements, keyed by the ID returned to the client.
132    prepared: std::collections::HashMap<u64, StdioPreparedStatement>,
133}
134
135impl Session {
136    pub(crate) fn new() -> Self {
137        Self {
138            next_tx_id: 1,
139            current_tx: None,
140            next_cursor_id: 1,
141            cursors: std::collections::HashMap::new(),
142            next_prepared_id: 1,
143            prepared: std::collections::HashMap::new(),
144        }
145    }
146
147    fn open_tx(&mut self) -> Result<u64, (&'static str, String)> {
148        if let Some(tx) = &self.current_tx {
149            return Err((
150                error_code::TX_ALREADY_OPEN,
151                format!("transaction {} already open in this session", tx.tx_id),
152            ));
153        }
154        let tx_id = self.next_tx_id;
155        self.next_tx_id = self.next_tx_id.saturating_add(1);
156        self.current_tx = Some(OpenTx {
157            tx_id,
158            write_set: Vec::new(),
159        });
160        Ok(tx_id)
161    }
162
163    fn take_tx(&mut self) -> Option<OpenTx> {
164        self.current_tx.take()
165    }
166
167    fn current_tx_mut(&mut self) -> Option<&mut OpenTx> {
168        self.current_tx.as_mut()
169    }
170
171    #[allow(dead_code)]
172    fn has_tx(&self) -> bool {
173        self.current_tx.is_some()
174    }
175
176    /// Register a freshly materialised cursor and return its id.
177    /// Enforces [`MAX_CURSORS_PER_SESSION`] before allocating.
178    fn insert_cursor(&mut self, cursor: Cursor) -> Result<u64, (&'static str, String)> {
179        if self.cursors.len() >= MAX_CURSORS_PER_SESSION {
180            return Err((
181                error_code::CURSOR_LIMIT_EXCEEDED,
182                format!(
183                    "session already holds {} cursors (max {}) — close some before opening new ones",
184                    self.cursors.len(),
185                    MAX_CURSORS_PER_SESSION
186                ),
187            ));
188        }
189        let id = self.next_cursor_id;
190        self.next_cursor_id = self.next_cursor_id.saturating_add(1);
191        let mut cursor = cursor;
192        cursor.cursor_id = id;
193        self.cursors.insert(id, cursor);
194        Ok(id)
195    }
196
197    fn cursor_mut(&mut self, id: u64) -> Option<&mut Cursor> {
198        self.cursors.get_mut(&id)
199    }
200
201    fn drop_cursor(&mut self, id: u64) -> Option<Cursor> {
202        self.cursors.remove(&id)
203    }
204
205    fn clear_cursors(&mut self) {
206        self.cursors.clear();
207    }
208}
209
210impl Default for Session {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216/// An in-flight transaction for a single stdio session.
217struct OpenTx {
218    tx_id: u64,
219    write_set: Vec<PendingSql>,
220}
221
222/// A buffered mutation waiting for `tx.commit`. Each variant carries a
223/// ready-to-execute SQL string so the replay loop is a straight
224/// `execute_query` call.
225enum PendingSql {
226    Insert(String),
227    Delete(String),
228    #[allow(dead_code)] // reserved for future query()-in-tx routing
229    Update(String),
230}
231
232impl PendingSql {
233    fn sql(&self) -> &str {
234        match self {
235            PendingSql::Insert(s) | PendingSql::Delete(s) | PendingSql::Update(s) => s,
236        }
237    }
238}
239
240/// An open streaming cursor over a materialised query result.
241///
242/// MVP model: the underlying [`RuntimeQueryResult`] has already been
243/// fully executed at `query.open` time and lives inside the cursor.
244/// Each `query.next` call slices off `batch_size` rows from the tail and
245/// advances `position`. This pays normal memory cost but lets the client
246/// consume the result in chunks, abort mid-stream, or pipeline the next
247/// batch request while processing the previous one.
248///
249/// A future iteration can swap the rows field for a lazy iterator pulled
250/// from the execution engine without changing the wire protocol.
251pub(crate) struct Cursor {
252    cursor_id: u64,
253    columns: Vec<String>,
254    rows: Vec<UnifiedRecord>,
255    position: usize,
256}
257
258impl Cursor {
259    fn new(columns: Vec<String>, rows: Vec<UnifiedRecord>) -> Self {
260        Self {
261            cursor_id: 0, // overwritten by Session::insert_cursor
262            columns,
263            rows,
264            position: 0,
265        }
266    }
267
268    fn total(&self) -> usize {
269        self.rows.len()
270    }
271
272    fn remaining(&self) -> usize {
273        self.rows.len().saturating_sub(self.position)
274    }
275
276    fn is_exhausted(&self) -> bool {
277        self.position >= self.rows.len()
278    }
279
280    /// Extract up to `batch_size` rows from the current position forward.
281    /// Advances the position to the end of the returned slice.
282    fn take_batch(&mut self, batch_size: usize) -> &[UnifiedRecord] {
283        let end = (self.position + batch_size).min(self.rows.len());
284        let slice = &self.rows[self.position..end];
285        self.position = end;
286        slice
287    }
288}
289
290/// Run the stdio JSON-RPC loop against a local in-process runtime.
291///
292/// Returns the process exit code. `0` on normal shutdown (EOF or
293/// explicit `close`). Non-zero only on fatal I/O errors reading
294/// stdin or writing stdout.
295pub fn run(runtime: &RedDBRuntime) -> i32 {
296    run_with_io(runtime, std::io::stdin(), &mut std::io::stdout())
297}
298
299/// Run the stdio JSON-RPC loop as a proxy to a remote gRPC server.
300///
301/// Every method is forwarded via tonic. This is what
302/// `red rpc --stdio --connect grpc://host:port` uses, and it is also
303/// what the JS and Python drivers spawn when the user calls
304/// `connect("grpc://...")`.
305pub fn run_remote(endpoint: &str, token: Option<String>) -> i32 {
306    let tokio_rt = match tokio::runtime::Builder::new_current_thread()
307        .enable_all()
308        .build()
309    {
310        Ok(rt) => rt,
311        Err(e) => {
312            tracing::error!(err = %e, "rpc: failed to build tokio runtime");
313            return 1;
314        }
315    };
316    let client = match tokio_rt.block_on(RedDBClient::connect(endpoint, token)) {
317        Ok(c) => c,
318        Err(e) => {
319            tracing::error!(endpoint, err = %e, "rpc: failed to connect");
320            return 1;
321        }
322    };
323    let backend = Backend::Remote(Box::new(RemoteBackend {
324        client: AsyncMutex::new(client),
325        tokio_rt: &tokio_rt,
326    }));
327    run_backend(&backend, std::io::stdin(), &mut std::io::stdout())
328}
329
330/// Same as [`run`] but takes explicit I/O handles. Used by tests.
331pub fn run_with_io<W: Write>(runtime: &RedDBRuntime, stdin: Stdin, stdout: &mut W) -> i32 {
332    run_backend(&Backend::Local(runtime), stdin, stdout)
333}
334
335/// Per-stdio-session connection-id counter. Each session captures a
336/// unique id so its `tx.commit` BEGIN/COMMIT pair routes to a distinct
337/// `TxnContext` in the runtime — without this every stdio session
338/// would share `conn_id = 0` and trample each other's transactions.
339/// Starts at a high base so we don't collide with PG-wire / gRPC
340/// transports that allocate from their own pools below.
341static STDIO_SESSION_CONN_ID: std::sync::atomic::AtomicU64 =
342    std::sync::atomic::AtomicU64::new(1_000_000);
343
344fn next_stdio_conn_id() -> u64 {
345    STDIO_SESSION_CONN_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
346}
347
348fn run_backend<W: Write>(backend: &Backend<'_>, stdin: Stdin, stdout: &mut W) -> i32 {
349    let reader = BufReader::new(stdin.lock());
350    let mut session = Session::new();
351    // Bind the session to a stable connection id so the runtime's
352    // `tx_contexts` (keyed by conn_id) survives across `handle_line`
353    // calls within the same session.
354    let conn_id = next_stdio_conn_id();
355    crate::runtime::impl_core::set_current_connection_id(conn_id);
356    for line_result in reader.lines() {
357        let line = match line_result {
358            Ok(l) => l,
359            Err(e) => {
360                let _ = writeln!(
361                    stdout,
362                    "{}",
363                    error_response(&Value::Null, error_code::INTERNAL_ERROR, &e.to_string())
364                );
365                let _ = stdout.flush();
366                return 1;
367            }
368        };
369        if line.trim().is_empty() {
370            continue;
371        }
372        let response = handle_line(backend, &mut session, &line);
373        if writeln!(stdout, "{}", response).is_err() || stdout.flush().is_err() {
374            return 1;
375        }
376        if response.contains("\"__close__\":true") {
377            return 0;
378        }
379    }
380    // EOF: silently drop any open transaction — atomicity is preserved
381    // (nothing was ever applied to the store) and no error is surfaced to
382    // the caller because EOF may be graceful client disconnect.
383    let _ = session.take_tx();
384    crate::runtime::impl_core::clear_current_connection_id();
385    0
386}
387
388/// Parse one input line and dispatch. Always returns a single-line
389/// JSON string suitable for direct write to stdout. Never panics
390/// (panics inside handlers are caught and reported).
391fn handle_line(backend: &Backend<'_>, session: &mut Session, line: &str) -> String {
392    let parsed: Value = match json::from_str(line) {
393        Ok(v) => v,
394        Err(err) => {
395            return error_response(
396                &Value::Null,
397                error_code::PARSE_ERROR,
398                &format!("invalid JSON: {err}"),
399            );
400        }
401    };
402
403    let id = parsed.get("id").cloned().unwrap_or(Value::Null);
404
405    let method = match parsed.get("method").and_then(Value::as_str) {
406        Some(m) => m.to_string(),
407        None => {
408            return error_response(&id, error_code::INVALID_REQUEST, "missing 'method' field");
409        }
410    };
411
412    let params = parsed.get("params").cloned().unwrap_or(Value::Null);
413
414    let dispatch = std::panic::catch_unwind(AssertUnwindSafe(|| match backend {
415        Backend::Local(rt) => dispatch_method(rt, session, &method, &params),
416        Backend::Remote(remote) => {
417            // Transactions are session-local and the remote path forwards
418            // each call independently — there is no place to park a tx
419            // handle across gRPC hops yet. Surface a clear error so
420            // drivers can fall back to per-call auto-commit.
421            if matches!(
422                method.as_str(),
423                "tx.begin"
424                    | "tx.commit"
425                    | "tx.rollback"
426                    | "query.open"
427                    | "query.next"
428                    | "query.close"
429            ) {
430                Err((
431                    error_code::TX_NOT_SUPPORTED_REMOTE,
432                    format!("{method} is not supported over remote gRPC yet"),
433                ))
434            } else {
435                dispatch_method_remote(&remote.client, remote.tokio_rt, &method, &params)
436            }
437        }
438    }));
439
440    match dispatch {
441        Ok(Ok(result)) => success_response(&id, &result, method == "close"),
442        Ok(Err((code, msg))) => error_response(&id, code, &msg),
443        Err(_) => error_response(&id, error_code::INTERNAL_ERROR, "handler panicked (caught)"),
444    }
445}
446
447/// Dispatch a parsed method call. Returns the `result` value on
448/// success or `(error_code, message)` on failure.
449fn dispatch_method(
450    runtime: &RedDBRuntime,
451    session: &mut Session,
452    method: &str,
453    params: &Value,
454) -> Result<Value, (&'static str, String)> {
455    match method {
456        "tx.begin" => {
457            let tx_id = session.open_tx()?;
458            Ok(Value::Object(
459                [
460                    ("tx_id".to_string(), Value::Number(tx_id as f64)),
461                    (
462                        "isolation".to_string(),
463                        Value::String("read_committed_deferred".to_string()),
464                    ),
465                ]
466                .into_iter()
467                .collect(),
468            ))
469        }
470
471        "tx.commit" => {
472            let tx = session.take_tx().ok_or((
473                error_code::NO_TX_OPEN,
474                "no transaction is open in this session".to_string(),
475            ))?;
476            let tx_id = tx.tx_id;
477            let op_count = tx.write_set.len();
478
479            // Drive the replay through a real engine transaction so
480            // failures roll back the buffered write_set atomically.
481            // Replaces the legacy `commit_lock`-serialised replay:
482            // cross-session ordering is now provided by the
483            // snapshot-manager's xid allocation, which is what the
484            // SQL `BEGIN`/`COMMIT` path has used since #31.
485            let replay: Result<(u64, usize), (usize, String)> = (|| {
486                runtime
487                    .execute_query("BEGIN")
488                    .map_err(|e| (0usize, format!("BEGIN: {e}")))?;
489                let mut total_affected: u64 = 0;
490                for (idx, op) in tx.write_set.iter().enumerate() {
491                    match runtime.execute_query(op.sql()) {
492                        Ok(qr) => total_affected += qr.affected_rows,
493                        Err(e) => {
494                            let _ = runtime.execute_query("ROLLBACK");
495                            return Err((idx, e.to_string()));
496                        }
497                    }
498                }
499                runtime
500                    .execute_query("COMMIT")
501                    .map_err(|e| (op_count, format!("COMMIT: {e}")))?;
502                Ok((total_affected, op_count))
503            })();
504
505            match replay {
506                Ok((affected, replayed)) => Ok(Value::Object(
507                    [
508                        ("tx_id".to_string(), Value::Number(tx_id as f64)),
509                        ("ops_replayed".to_string(), Value::Number(replayed as f64)),
510                        ("affected".to_string(), Value::Number(affected as f64)),
511                    ]
512                    .into_iter()
513                    .collect(),
514                )),
515                Err((failed_idx, msg)) => Err((
516                    error_code::TX_REPLAY_FAILED,
517                    format!(
518                        "tx {tx_id} replay failed at op {failed_idx}/{op_count}: {msg} \
519                         (ops 0..{failed_idx} already applied and are NOT rolled back)"
520                    ),
521                )),
522            }
523        }
524
525        "query.open" => {
526            let sql = params.get("sql").and_then(Value::as_str).ok_or((
527                error_code::INVALID_PARAMS,
528                "missing 'sql' string".to_string(),
529            ))?;
530            let qr = runtime
531                .execute_query(sql)
532                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
533
534            // Extract the column list from the first record. Consistent
535            // with query_result_to_json which uses the first row's keys
536            // as schema.
537            let columns: Vec<String> = qr
538                .result
539                .records
540                .first()
541                .map(|first| {
542                    let mut keys: Vec<String> = first
543                        .column_names()
544                        .into_iter()
545                        .map(|k| k.to_string())
546                        .collect();
547                    keys.sort();
548                    keys
549                })
550                .unwrap_or_default();
551
552            let cursor = Cursor::new(columns.clone(), qr.result.records);
553            let total = cursor.total();
554            let cursor_id = session.insert_cursor(cursor)?;
555
556            Ok(Value::Object(
557                [
558                    ("cursor_id".to_string(), Value::Number(cursor_id as f64)),
559                    (
560                        "columns".to_string(),
561                        Value::Array(columns.into_iter().map(Value::String).collect()),
562                    ),
563                    ("total_rows".to_string(), Value::Number(total as f64)),
564                ]
565                .into_iter()
566                .collect(),
567            ))
568        }
569
570        "query.next" => {
571            let cursor_id = params
572                .get("cursor_id")
573                .and_then(|v| v.as_f64())
574                .map(|n| n as u64)
575                .ok_or((
576                    error_code::INVALID_PARAMS,
577                    "missing 'cursor_id' number".to_string(),
578                ))?;
579            let batch_size = params
580                .get("batch_size")
581                .and_then(|v| v.as_f64())
582                .map(|n| n as usize)
583                .unwrap_or(DEFAULT_CURSOR_BATCH_SIZE)
584                .clamp(1, MAX_CURSOR_BATCH_SIZE);
585
586            // Extract the batch inside a bounded borrow so we can
587            // drop the cursor afterwards without borrow-conflict.
588            let (rows, done, remaining) = {
589                let cursor = session.cursor_mut(cursor_id).ok_or((
590                    error_code::CURSOR_NOT_FOUND,
591                    format!("cursor {cursor_id} not found"),
592                ))?;
593                let batch = cursor.take_batch(batch_size);
594                let rows_json: Vec<Value> = batch.iter().map(record_to_json_object).collect();
595                (rows_json, cursor.is_exhausted(), cursor.remaining())
596            };
597
598            if done {
599                // Auto-drop exhausted cursors so long-lived sessions
600                // don't accumulate dead state.
601                let _ = session.drop_cursor(cursor_id);
602            }
603
604            Ok(Value::Object(
605                [
606                    ("cursor_id".to_string(), Value::Number(cursor_id as f64)),
607                    ("rows".to_string(), Value::Array(rows)),
608                    ("done".to_string(), Value::Bool(done)),
609                    ("remaining".to_string(), Value::Number(remaining as f64)),
610                ]
611                .into_iter()
612                .collect(),
613            ))
614        }
615
616        "query.close" => {
617            let cursor_id = params
618                .get("cursor_id")
619                .and_then(|v| v.as_f64())
620                .map(|n| n as u64)
621                .ok_or((
622                    error_code::INVALID_PARAMS,
623                    "missing 'cursor_id' number".to_string(),
624                ))?;
625            let existed = session.drop_cursor(cursor_id).is_some();
626            if !existed {
627                return Err((
628                    error_code::CURSOR_NOT_FOUND,
629                    format!("cursor {cursor_id} not found"),
630                ));
631            }
632            Ok(Value::Object(
633                [
634                    ("cursor_id".to_string(), Value::Number(cursor_id as f64)),
635                    ("closed".to_string(), Value::Bool(true)),
636                ]
637                .into_iter()
638                .collect(),
639            ))
640        }
641
642        "tx.rollback" => {
643            let tx = session.take_tx().ok_or((
644                error_code::NO_TX_OPEN,
645                "no transaction is open in this session".to_string(),
646            ))?;
647            let ops_discarded = tx.write_set.len();
648            Ok(Value::Object(
649                [
650                    ("tx_id".to_string(), Value::Number(tx.tx_id as f64)),
651                    (
652                        "ops_discarded".to_string(),
653                        Value::Number(ops_discarded as f64),
654                    ),
655                ]
656                .into_iter()
657                .collect(),
658            ))
659        }
660
661        "version" => Ok(Value::Object(
662            [
663                (
664                    "version".to_string(),
665                    Value::String(env!("CARGO_PKG_VERSION").to_string()),
666                ),
667                (
668                    "protocol".to_string(),
669                    Value::String(PROTOCOL_VERSION.to_string()),
670                ),
671            ]
672            .into_iter()
673            .collect(),
674        )),
675
676        "health" => Ok(Value::Object(
677            [
678                ("ok".to_string(), Value::Bool(true)),
679                (
680                    "version".to_string(),
681                    Value::String(env!("CARGO_PKG_VERSION").to_string()),
682                ),
683            ]
684            .into_iter()
685            .collect(),
686        )),
687
688        "query" => {
689            let sql = params.get("sql").and_then(Value::as_str).ok_or((
690                error_code::INVALID_PARAMS,
691                "missing 'sql' string".to_string(),
692            ))?;
693
694            // Optional positional `$N` bind parameters (#353 tracer slice).
695            // Absence preserves the legacy single-arg `query(sql)` path.
696            let bind_values: Option<Vec<SchemaValue>> = params
697                .get("params")
698                .map(|v| {
699                    v.as_array()
700                        .ok_or((
701                            error_code::INVALID_PARAMS,
702                            "'params' must be an array".to_string(),
703                        ))
704                        .map(|arr| arr.iter().map(json_value_to_schema_value).collect())
705                })
706                .transpose()?;
707
708            if let Some(binds) = bind_values {
709                let qr = runtime
710                    .execute_query_with_params(sql, &binds)
711                    .map_err(|e| {
712                        if matches!(e, crate::api::RedDBError::Validation { .. }) {
713                            (error_code::INVALID_PARAMS, e.to_string())
714                        } else {
715                            (error_code::QUERY_ERROR, e.to_string())
716                        }
717                    })?;
718                return Ok(query_result_to_json(&qr));
719            }
720
721            let qr = runtime
722                .execute_query(sql)
723                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
724            Ok(query_result_to_json(&qr))
725        }
726
727        // ── Prepared statements ──────────────────────────────────────────────
728        //
729        // `prepare` parses the SQL once, extracts a parameterized shape, and
730        // returns a `prepared_id` the client can reuse. `execute_prepared` takes
731        // that id plus JSON-encoded bind values and runs the plan without parsing.
732        //
733        // This mirrors the PostgreSQL extended-query protocol semantics and is the
734        // server-side half of the client driver's `PreparedStatement` abstraction.
735        "prepare" => {
736            use crate::storage::query::modes::parse_multi;
737            use crate::storage::query::planner::shape::parameterize_query_expr;
738
739            let sql = params.get("sql").and_then(Value::as_str).ok_or((
740                error_code::INVALID_PARAMS,
741                "missing 'sql' string".to_string(),
742            ))?;
743            let parsed = parse_multi(sql).map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
744            let (shape, parameter_count) = if let Some(prepared) = parameterize_query_expr(&parsed)
745            {
746                (prepared.shape, prepared.parameter_count)
747            } else {
748                (parsed, 0)
749            };
750            let id = session.next_prepared_id;
751            session.next_prepared_id = session.next_prepared_id.saturating_add(1);
752            session.prepared.insert(
753                id,
754                StdioPreparedStatement {
755                    shape,
756                    parameter_count,
757                },
758            );
759            Ok(Value::Object(
760                [
761                    ("prepared_id".to_string(), Value::Number(id as f64)),
762                    (
763                        "parameter_count".to_string(),
764                        Value::Number(parameter_count as f64),
765                    ),
766                ]
767                .into_iter()
768                .collect(),
769            ))
770        }
771
772        "execute_prepared" => {
773            use crate::storage::query::planner::shape::bind_parameterized_query;
774            use crate::storage::schema::Value as SV;
775
776            let id = params
777                .get("prepared_id")
778                .and_then(Value::as_f64)
779                .map(|n| n as u64)
780                .ok_or((
781                    error_code::INVALID_PARAMS,
782                    "missing 'prepared_id'".to_string(),
783                ))?;
784
785            let stmt = session.prepared.get(&id).ok_or((
786                error_code::QUERY_ERROR,
787                format!("no prepared statement with id {id}"),
788            ))?;
789
790            // Parse bind values from JSON array of JSON-encoded literals.
791            let binds_json: Vec<Value> = params
792                .get("binds")
793                .and_then(Value::as_array)
794                .map(|a| a.to_vec())
795                .unwrap_or_default();
796            if binds_json.len() != stmt.parameter_count {
797                return Err((
798                    error_code::INVALID_PARAMS,
799                    format!(
800                        "expected {} bind values, got {}",
801                        stmt.parameter_count,
802                        binds_json.len()
803                    ),
804                ));
805            }
806
807            // Convert JSON bind values to SchemaValue.
808            let binds: Vec<SV> = binds_json.iter().map(json_value_to_schema_value).collect();
809
810            // Bind literals into the parameterized shape.
811            let expr = if stmt.parameter_count == 0 {
812                stmt.shape.clone()
813            } else {
814                bind_parameterized_query(&stmt.shape, &binds, stmt.parameter_count)
815                    .ok_or((error_code::QUERY_ERROR, "bind failed".to_string()))?
816            };
817
818            let qr = runtime
819                .execute_query_expr(expr)
820                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
821            Ok(query_result_to_json(&qr))
822        }
823
824        "insert" => {
825            let collection = params.get("collection").and_then(Value::as_str).ok_or((
826                error_code::INVALID_PARAMS,
827                "missing 'collection' string".to_string(),
828            ))?;
829            let payload = params.get("payload").ok_or((
830                error_code::INVALID_PARAMS,
831                "missing 'payload' object".to_string(),
832            ))?;
833            let payload_obj = payload.as_object().ok_or((
834                error_code::INVALID_PARAMS,
835                "'payload' must be a JSON object".to_string(),
836            ))?;
837            if let Some(tx) = session.current_tx_mut() {
838                let sql = build_insert_sql(collection, payload_obj.iter());
839                tx.write_set.push(PendingSql::Insert(sql));
840                return Ok(pending_tx_response(tx.tx_id));
841            }
842
843            let output = runtime
844                .create_row(flat_payload_to_row_input(collection, payload_obj))
845                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
846            let mut out = json::Map::new();
847            out.insert("affected".to_string(), Value::Number(1.0));
848            out.insert("id".to_string(), Value::String(output.id.raw().to_string()));
849            Ok(Value::Object(out))
850        }
851
852        "bulk_insert" => {
853            let collection = params.get("collection").and_then(Value::as_str).ok_or((
854                error_code::INVALID_PARAMS,
855                "missing 'collection' string".to_string(),
856            ))?;
857            let payloads = params.get("payloads").and_then(Value::as_array).ok_or((
858                error_code::INVALID_PARAMS,
859                "missing 'payloads' array".to_string(),
860            ))?;
861
862            let mut objects = Vec::with_capacity(payloads.len());
863            for entry in payloads {
864                objects.push(entry.as_object().ok_or((
865                    error_code::INVALID_PARAMS,
866                    "each payload must be a JSON object".to_string(),
867                ))?);
868            }
869
870            if let Some(tx) = session.current_tx_mut() {
871                let mut buffered: u64 = 0;
872                for obj in &objects {
873                    let sql = build_insert_sql(collection, obj.iter());
874                    tx.write_set.push(PendingSql::Insert(sql));
875                    buffered += 1;
876                }
877                let tx_id = tx.tx_id;
878                return Ok(Value::Object(
879                    [
880                        ("affected".to_string(), Value::Number(0.0)),
881                        ("buffered".to_string(), Value::Number(buffered as f64)),
882                        ("pending".to_string(), Value::Bool(true)),
883                        ("tx_id".to_string(), Value::Number(tx_id as f64)),
884                    ]
885                    .into_iter()
886                    .collect(),
887                ));
888            }
889
890            if should_bulk_insert_graph(runtime, collection, &objects) {
891                return bulk_insert_graph(runtime, collection, &objects)
892                    .map_err(|e| (error_code::QUERY_ERROR, e.to_string()));
893            }
894
895            let mut total_affected: u64 = 0;
896            let mut ids = Vec::with_capacity(objects.len());
897            for chunk in objects.chunks(STDIO_BULK_INSERT_CHUNK_ROWS) {
898                let rows = chunk
899                    .iter()
900                    .map(|obj| flat_payload_to_row_input(collection, obj))
901                    .collect();
902                let outputs = runtime
903                    .create_rows_batch(CreateRowsBatchInput {
904                        collection: collection.to_string(),
905                        rows,
906                        suppress_events: false,
907                    })
908                    .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
909                total_affected += outputs.len() as u64;
910                ids.extend(
911                    outputs
912                        .into_iter()
913                        .map(|output| Value::String(output.id.raw().to_string())),
914                );
915            }
916            let mut out = json::Map::new();
917            out.insert("affected".to_string(), Value::Number(total_affected as f64));
918            out.insert("ids".to_string(), Value::Array(ids));
919            Ok(Value::Object(out))
920        }
921
922        "get" => {
923            let collection = params.get("collection").and_then(Value::as_str).ok_or((
924                error_code::INVALID_PARAMS,
925                "missing 'collection' string".to_string(),
926            ))?;
927            let id = params.get("id").and_then(Value::as_str).ok_or((
928                error_code::INVALID_PARAMS,
929                "missing 'id' string".to_string(),
930            ))?;
931            let sql = format!("SELECT * FROM {collection} WHERE rid = {id} LIMIT 1");
932            let qr = runtime
933                .execute_query(&sql)
934                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
935            let entity = qr
936                .result
937                .records
938                .first()
939                .map(record_to_json_object)
940                .unwrap_or(Value::Null);
941            Ok(Value::Object(
942                [("entity".to_string(), entity)].into_iter().collect(),
943            ))
944        }
945
946        "delete" => {
947            let collection = params.get("collection").and_then(Value::as_str).ok_or((
948                error_code::INVALID_PARAMS,
949                "missing 'collection' string".to_string(),
950            ))?;
951            let id = params.get("id").and_then(Value::as_str).ok_or((
952                error_code::INVALID_PARAMS,
953                "missing 'id' string".to_string(),
954            ))?;
955            let sql = format!("DELETE FROM {collection} WHERE rid = {id}");
956
957            if let Some(tx) = session.current_tx_mut() {
958                tx.write_set.push(PendingSql::Delete(sql));
959                return Ok(pending_tx_response(tx.tx_id));
960            }
961
962            let qr = runtime
963                .execute_query(&sql)
964                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
965            Ok(Value::Object(
966                [(
967                    "affected".to_string(),
968                    Value::Number(qr.affected_rows as f64),
969                )]
970                .into_iter()
971                .collect(),
972            ))
973        }
974
975        "close" => {
976            // Silently drop any open transaction and cursors on close.
977            // The client explicitly asked to terminate; surfacing an
978            // error here would leak state across what is effectively a
979            // reset.
980            let _ = session.take_tx();
981            session.clear_cursors();
982            let _ = runtime.checkpoint();
983            Ok(Value::Null)
984        }
985
986        // Auth surface — local stdio bridge has no auth backend
987        // (the spawned binary inherits the caller's privileges by
988        // construction). The remote bridge below maps these methods
989        // onto the gRPC server's auth endpoints.
990        "auth.login"
991        | "auth.whoami"
992        | "auth.change_password"
993        | "auth.create_api_key"
994        | "auth.revoke_api_key" => {
995            let _ = (session, params);
996            Err((
997                error_code::INVALID_REQUEST,
998                format!(
999                    "{method}: auth methods are only available on grpc:// connections; \
1000                     embedded modes (memory://, file://) inherit caller privileges"
1001                ),
1002            ))
1003        }
1004
1005        other => Err((
1006            error_code::INVALID_REQUEST,
1007            format!("unknown method: {other}"),
1008        )),
1009    }
1010}
1011
1012// ---------------------------------------------------------------------------
1013// Response builders
1014// ---------------------------------------------------------------------------
1015
1016fn success_response(id: &Value, result: &Value, is_close: bool) -> String {
1017    // For `close` we tag the response so the loop knows to exit after
1018    // flushing. The tag is stripped from the wire by replacing it
1019    // before serialization — actually we just include it as a sentinel
1020    // field that drivers ignore (forward compat).
1021    let mut envelope = json::Map::new();
1022    envelope.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1023    envelope.insert("id".to_string(), id.clone());
1024    envelope.insert("result".to_string(), result.clone());
1025    if is_close {
1026        envelope.insert("__close__".to_string(), Value::Bool(true));
1027    }
1028    Value::Object(envelope).to_string_compact()
1029}
1030
1031fn error_response(id: &Value, code: &str, message: &str) -> String {
1032    let mut err = json::Map::new();
1033    err.insert("code".to_string(), Value::String(code.to_string()));
1034    err.insert("message".to_string(), Value::String(message.to_string()));
1035    err.insert("data".to_string(), Value::Null);
1036
1037    let mut envelope = json::Map::new();
1038    envelope.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1039    envelope.insert("id".to_string(), id.clone());
1040    envelope.insert("error".to_string(), Value::Object(err));
1041    Value::Object(envelope).to_string_compact()
1042}
1043
1044// ---------------------------------------------------------------------------
1045// Helpers
1046// ---------------------------------------------------------------------------
1047
1048/// Envelope returned by `insert` and `delete` when the call was buffered
1049/// into an open transaction instead of being auto-committed.
1050fn pending_tx_response(tx_id: u64) -> Value {
1051    Value::Object(
1052        [
1053            ("affected".to_string(), Value::Number(0.0)),
1054            ("pending".to_string(), Value::Bool(true)),
1055            ("tx_id".to_string(), Value::Number(tx_id as f64)),
1056        ]
1057        .into_iter()
1058        .collect(),
1059    )
1060}
1061
1062pub(crate) fn build_insert_sql<'a, I>(collection: &str, fields: I) -> String
1063where
1064    I: Iterator<Item = (&'a String, &'a Value)>,
1065{
1066    let mut cols = Vec::new();
1067    let mut vals = Vec::new();
1068    for (k, v) in fields {
1069        cols.push(k.clone());
1070        vals.push(value_to_sql_literal(v));
1071    }
1072    format!(
1073        "INSERT INTO {collection} ({}) VALUES ({})",
1074        cols.join(", "),
1075        vals.join(", "),
1076    )
1077}
1078
1079fn flat_payload_to_row_input(
1080    collection: &str,
1081    payload: &json::Map<String, Value>,
1082) -> CreateRowInput {
1083    CreateRowInput {
1084        collection: collection.to_string(),
1085        fields: payload
1086            .iter()
1087            .map(|(key, value)| (key.clone(), json_value_to_schema_value(value)))
1088            .collect(),
1089        metadata: Vec::new(),
1090        node_links: Vec::new(),
1091        vector_links: Vec::new(),
1092    }
1093}
1094
1095fn bulk_insert_chunk_count(row_count: usize) -> usize {
1096    if row_count == 0 {
1097        0
1098    } else {
1099        ((row_count - 1) / STDIO_BULK_INSERT_CHUNK_ROWS) + 1
1100    }
1101}
1102
1103pub(crate) fn should_bulk_insert_graph(
1104    runtime: &RedDBRuntime,
1105    collection: &str,
1106    payloads: &[&json::Map<String, Value>],
1107) -> bool {
1108    let graph_shaped = payloads
1109        .iter()
1110        .all(|payload| payload.get("label").and_then(Value::as_str).is_some());
1111    if !graph_shaped {
1112        return false;
1113    }
1114
1115    matches!(
1116        runtime
1117            .db()
1118            .catalog_model_snapshot()
1119            .collections
1120            .iter()
1121            .find(|descriptor| descriptor.name == collection)
1122            .map(|descriptor| descriptor.declared_model.unwrap_or(descriptor.model)),
1123        Some(crate::catalog::CollectionModel::Graph | crate::catalog::CollectionModel::Mixed)
1124    )
1125}
1126
1127pub(crate) fn bulk_insert_graph(
1128    runtime: &RedDBRuntime,
1129    collection: &str,
1130    payloads: &[&json::Map<String, Value>],
1131) -> crate::RedDBResult<Value> {
1132    use crate::application::entity_payload::{parse_create_edge_input, parse_create_node_input};
1133    use crate::application::ports::RuntimeEntityPort;
1134
1135    let mut ids = Vec::with_capacity(payloads.len());
1136    for payload in payloads {
1137        let input_payload = normalize_flat_graph_payload(payload);
1138        let id = if payload.contains_key("from") || payload.contains_key("to") {
1139            runtime
1140                .create_edge(parse_create_edge_input(
1141                    collection.to_string(),
1142                    &input_payload,
1143                )?)?
1144                .id
1145        } else {
1146            runtime
1147                .create_node(parse_create_node_input(
1148                    collection.to_string(),
1149                    &input_payload,
1150                )?)?
1151                .id
1152        };
1153        ids.push(Value::Number(id.raw() as f64));
1154    }
1155
1156    let mut out = json::Map::new();
1157    out.insert("affected".to_string(), Value::Number(ids.len() as f64));
1158    out.insert("ids".to_string(), Value::Array(ids));
1159    Ok(Value::Object(out))
1160}
1161
1162fn normalize_flat_graph_payload(payload: &json::Map<String, Value>) -> Value {
1163    if payload.contains_key("properties") || payload.contains_key("fields") {
1164        return Value::Object(payload.clone());
1165    }
1166
1167    let is_edge = payload.contains_key("from") || payload.contains_key("to");
1168    let mut normalized = payload.clone();
1169    let mut properties = json::Map::new();
1170    for (key, value) in payload {
1171        let reserved = if is_edge {
1172            matches!(
1173                key.as_str(),
1174                "label"
1175                    | "from"
1176                    | "to"
1177                    | "weight"
1178                    | "metadata"
1179                    | "properties"
1180                    | "fields"
1181                    | "_ttl_ms"
1182                    | "_expires_at"
1183            )
1184        } else {
1185            matches!(
1186                key.as_str(),
1187                "label"
1188                    | "node_type"
1189                    | "metadata"
1190                    | "links"
1191                    | "embeddings"
1192                    | "properties"
1193                    | "fields"
1194                    | "_ttl_ms"
1195                    | "_expires_at"
1196            )
1197        };
1198        if !reserved {
1199            properties.insert(key.clone(), value.clone());
1200        }
1201    }
1202    if !properties.is_empty() {
1203        normalized.insert("properties".to_string(), Value::Object(properties));
1204    }
1205    Value::Object(normalized)
1206}
1207
1208pub(crate) fn value_to_sql_literal(v: &Value) -> String {
1209    match v {
1210        Value::Null => "NULL".to_string(),
1211        Value::Bool(b) => b.to_string(),
1212        Value::Number(n) => {
1213            if n.fract() == 0.0 {
1214                format!("{}", *n as i64)
1215            } else {
1216                n.to_string()
1217            }
1218        }
1219        Value::String(s) => format!("'{}'", s.replace('\'', "''")),
1220        other => format!("'{}'", other.to_string_compact().replace('\'', "''")),
1221    }
1222}
1223
1224pub(crate) fn query_result_to_json(qr: &RuntimeQueryResult) -> Value {
1225    if let Some(ask) = ask_query_result_to_json(qr) {
1226        return ask;
1227    }
1228
1229    let mut envelope = json::Map::new();
1230    envelope.insert(
1231        "statement".to_string(),
1232        Value::String(qr.statement_type.to_string()),
1233    );
1234    envelope.insert(
1235        "affected".to_string(),
1236        Value::Number(qr.affected_rows as f64),
1237    );
1238
1239    let mut columns = Vec::new();
1240    if let Some(first) = qr.result.records.first() {
1241        let mut keys: Vec<String> = first
1242            .column_names()
1243            .into_iter()
1244            .map(|k| k.to_string())
1245            .collect();
1246        keys.sort();
1247        columns = keys.into_iter().map(Value::String).collect();
1248    }
1249    envelope.insert("columns".to_string(), Value::Array(columns));
1250
1251    let rows: Vec<Value> = qr
1252        .result
1253        .records
1254        .iter()
1255        .map(record_to_json_object)
1256        .collect();
1257    envelope.insert("rows".to_string(), Value::Array(rows));
1258
1259    Value::Object(envelope)
1260}
1261
1262fn ask_query_result_to_json(qr: &RuntimeQueryResult) -> Option<Value> {
1263    if qr.statement != "ask" {
1264        return None;
1265    }
1266    let row = qr.result.records.first()?;
1267    let answer = text_field(row, "answer")?;
1268    let provider = text_field(row, "provider").unwrap_or_default();
1269    let model = text_field(row, "model").unwrap_or_default();
1270    let sources_flat_json = json_field(row, "sources_flat").unwrap_or(Value::Array(Vec::new()));
1271    let citations_json = json_field(row, "citations").unwrap_or(Value::Array(Vec::new()));
1272    let validation_json = json_field(row, "validation").unwrap_or(Value::Object(json::Map::new()));
1273
1274    let effective_mode = match text_field(row, "mode").as_deref() {
1275        Some("lenient") => crate::runtime::ai::ask_response_envelope::Mode::Lenient,
1276        _ => crate::runtime::ai::ask_response_envelope::Mode::Strict,
1277    };
1278
1279    let result = crate::runtime::ai::ask_response_envelope::AskResult {
1280        answer,
1281        sources_flat: envelope_sources_flat(&sources_flat_json),
1282        citations: envelope_citations(&citations_json),
1283        validation: envelope_validation(&validation_json),
1284        cache_hit: bool_field(row, "cache_hit").unwrap_or(false),
1285        provider,
1286        model,
1287        prompt_tokens: u32_field(row, "prompt_tokens").unwrap_or(0),
1288        completion_tokens: u32_field(row, "completion_tokens").unwrap_or(0),
1289        cost_usd: f64_field(row, "cost_usd").unwrap_or(0.0),
1290        effective_mode,
1291        retry_count: u32_field(row, "retry_count").unwrap_or(0),
1292    };
1293    Some(crate::runtime::ai::ask_response_envelope::build(&result))
1294}
1295
1296fn record_field<'a>(record: &'a UnifiedRecord, name: &str) -> Option<&'a SchemaValue> {
1297    record
1298        .iter_fields()
1299        .find_map(|(key, value)| (key.as_ref() == name).then_some(value))
1300}
1301
1302fn text_field(record: &UnifiedRecord, name: &str) -> Option<String> {
1303    match record_field(record, name)? {
1304        SchemaValue::Text(s) => Some(s.to_string()),
1305        SchemaValue::Email(s)
1306        | SchemaValue::Url(s)
1307        | SchemaValue::NodeRef(s)
1308        | SchemaValue::EdgeRef(s) => Some(s.clone()),
1309        other => Some(format!("{other}")),
1310    }
1311}
1312
1313fn u32_field(record: &UnifiedRecord, name: &str) -> Option<u32> {
1314    match record_field(record, name)? {
1315        SchemaValue::Integer(n) => (*n >= 0).then_some((*n).min(u32::MAX as i64) as u32),
1316        SchemaValue::UnsignedInteger(n) => Some((*n).min(u32::MAX as u64) as u32),
1317        SchemaValue::BigInt(n)
1318        | SchemaValue::TimestampMs(n)
1319        | SchemaValue::Timestamp(n)
1320        | SchemaValue::Duration(n)
1321        | SchemaValue::Decimal(n) => (*n >= 0).then_some((*n).min(u32::MAX as i64) as u32),
1322        SchemaValue::Float(n) => (*n >= 0.0).then_some((*n).min(u32::MAX as f64) as u32),
1323        _ => None,
1324    }
1325}
1326
1327fn f64_field(record: &UnifiedRecord, name: &str) -> Option<f64> {
1328    match record_field(record, name)? {
1329        SchemaValue::Integer(n) => Some(*n as f64),
1330        SchemaValue::UnsignedInteger(n) => Some(*n as f64),
1331        SchemaValue::BigInt(n)
1332        | SchemaValue::TimestampMs(n)
1333        | SchemaValue::Timestamp(n)
1334        | SchemaValue::Duration(n)
1335        | SchemaValue::Decimal(n) => Some(*n as f64),
1336        SchemaValue::Float(n) => Some(*n),
1337        _ => None,
1338    }
1339}
1340
1341fn bool_field(record: &UnifiedRecord, name: &str) -> Option<bool> {
1342    match record_field(record, name)? {
1343        SchemaValue::Boolean(value) => Some(*value),
1344        _ => None,
1345    }
1346}
1347
1348fn json_field(record: &UnifiedRecord, name: &str) -> Option<Value> {
1349    match record_field(record, name)? {
1350        SchemaValue::Json(bytes) => {
1351            // Decode the native binary document-body container (PRD-1398) if present.
1352            crate::document_body::decode_container_to_json(bytes)
1353                .or_else(|| json::from_slice(bytes).ok())
1354        }
1355        SchemaValue::Text(text) => json::from_str(text).ok(),
1356        _ => None,
1357    }
1358}
1359
1360fn envelope_sources_flat(
1361    value: &Value,
1362) -> Vec<crate::runtime::ai::ask_response_envelope::SourceRow> {
1363    value
1364        .as_array()
1365        .unwrap_or(&[])
1366        .iter()
1367        .filter_map(|source| {
1368            let urn = source.get("urn").and_then(Value::as_str)?.to_string();
1369            let payload = source
1370                .get("payload")
1371                .and_then(Value::as_str)
1372                .map(ToString::to_string)
1373                .unwrap_or_else(|| source.to_string_compact());
1374            Some(crate::runtime::ai::ask_response_envelope::SourceRow { urn, payload })
1375        })
1376        .collect()
1377}
1378
1379fn envelope_citations(value: &Value) -> Vec<crate::runtime::ai::ask_response_envelope::Citation> {
1380    value
1381        .as_array()
1382        .unwrap_or(&[])
1383        .iter()
1384        .filter_map(|citation| {
1385            let marker = citation.get("marker").and_then(Value::as_u64)?;
1386            let urn = citation.get("urn").and_then(Value::as_str)?.to_string();
1387            Some(crate::runtime::ai::ask_response_envelope::Citation {
1388                marker: marker.min(u32::MAX as u64) as u32,
1389                urn,
1390            })
1391        })
1392        .collect()
1393}
1394
1395fn envelope_validation(value: &Value) -> crate::runtime::ai::ask_response_envelope::Validation {
1396    crate::runtime::ai::ask_response_envelope::Validation {
1397        ok: value.get("ok").and_then(Value::as_bool).unwrap_or(true),
1398        warnings: validation_items(value, "warnings")
1399            .into_iter()
1400            .map(
1401                |(kind, detail)| crate::runtime::ai::ask_response_envelope::ValidationWarning {
1402                    kind,
1403                    detail,
1404                },
1405            )
1406            .collect(),
1407        errors: validation_items(value, "errors")
1408            .into_iter()
1409            .map(
1410                |(kind, detail)| crate::runtime::ai::ask_response_envelope::ValidationError {
1411                    kind,
1412                    detail,
1413                },
1414            )
1415            .collect(),
1416    }
1417}
1418
1419fn validation_items(value: &Value, key: &str) -> Vec<(String, String)> {
1420    value
1421        .get(key)
1422        .and_then(Value::as_array)
1423        .unwrap_or(&[])
1424        .iter()
1425        .filter_map(|item| {
1426            Some((
1427                item.get("kind").and_then(Value::as_str)?.to_string(),
1428                item.get("detail")
1429                    .and_then(Value::as_str)
1430                    .unwrap_or("")
1431                    .to_string(),
1432            ))
1433        })
1434        .collect()
1435}
1436
1437pub(crate) fn insert_result_to_json(qr: &RuntimeQueryResult) -> Value {
1438    let mut envelope = json::Map::new();
1439    envelope.insert(
1440        "affected".to_string(),
1441        Value::Number(qr.affected_rows as f64),
1442    );
1443    // First row of the result, if any, contains the inserted entity id.
1444    if let Some(first) = qr.result.records.first() {
1445        if let Some(id_val) = first
1446            .iter_fields()
1447            .find(|(k, _)| {
1448                let s: &str = k;
1449                s == "_entity_id"
1450            })
1451            .map(|(_, v)| schema_value_to_json(v))
1452        {
1453            envelope.insert("id".to_string(), id_val);
1454        }
1455    }
1456    Value::Object(envelope)
1457}
1458
1459fn record_to_json_object(record: &UnifiedRecord) -> Value {
1460    let mut map = json::Map::new();
1461    // iter_fields merges the columnar fast-path + HashMap so scan
1462    // rows (columnar only) contribute their values.
1463    let mut entries: Vec<(&str, &SchemaValue)> =
1464        record.iter_fields().map(|(k, v)| (k.as_ref(), v)).collect();
1465    entries.sort_by(|a, b| a.0.cmp(b.0));
1466    for (k, v) in entries {
1467        map.insert(k.to_string(), schema_value_to_json(v));
1468    }
1469    Value::Object(map)
1470}
1471
1472fn schema_value_to_json(v: &SchemaValue) -> Value {
1473    match v {
1474        SchemaValue::Null => Value::Null,
1475        SchemaValue::Boolean(b) => Value::Bool(*b),
1476        SchemaValue::Integer(n) => Value::Number(*n as f64),
1477        SchemaValue::UnsignedInteger(n) => Value::Number(*n as f64),
1478        SchemaValue::Float(n) if n.is_finite() => Value::Number(*n),
1479        SchemaValue::Float(n) => {
1480            let token = if n.is_nan() {
1481                "NaN"
1482            } else if n.is_sign_positive() {
1483                "Infinity"
1484            } else {
1485                "-Infinity"
1486            };
1487            single_key_object("$float", Value::String(token.to_string()))
1488        }
1489        SchemaValue::BigInt(n) => Value::Number(*n as f64),
1490        SchemaValue::TimestampMs(n) | SchemaValue::Duration(n) | SchemaValue::Decimal(n) => {
1491            Value::Number(*n as f64)
1492        }
1493        SchemaValue::Timestamp(n) => single_key_object("$ts", Value::String(n.to_string())),
1494        SchemaValue::Password(_) | SchemaValue::Secret(_) => Value::String("***".to_string()),
1495        SchemaValue::Text(s) => Value::String(s.to_string()),
1496        SchemaValue::Blob(bytes) => {
1497            single_key_object("$bytes", Value::String(base64_encode(bytes)))
1498        }
1499        SchemaValue::Json(bytes) => {
1500            crate::presentation::entity_json::storage_json_bytes_to_json(bytes)
1501        }
1502        SchemaValue::Uuid(bytes) => single_key_object("$uuid", Value::String(format_uuid(bytes))),
1503        SchemaValue::Email(s)
1504        | SchemaValue::Url(s)
1505        | SchemaValue::NodeRef(s)
1506        | SchemaValue::EdgeRef(s) => Value::String(s.clone()),
1507        other => Value::String(format!("{other}")),
1508    }
1509}
1510
1511fn single_key_object(key: &str, value: Value) -> Value {
1512    Value::Object([(key.to_string(), value)].into_iter().collect())
1513}
1514
1515/// Convert a JSON `Value` to a `SchemaValue` for use as a bind parameter
1516/// in a prepared statement. JSON-RPC envelopes preserve values that
1517/// ordinary JSON cannot represent losslessly.
1518pub(crate) fn json_value_to_schema_value(v: &Value) -> SchemaValue {
1519    match v {
1520        Value::Null => SchemaValue::Null,
1521        Value::Bool(b) => SchemaValue::Boolean(*b),
1522        Value::Number(n) => {
1523            if n.is_finite() && n.fract() == 0.0 && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
1524                SchemaValue::Integer(*n as i64)
1525            } else {
1526                SchemaValue::Float(*n)
1527            }
1528        }
1529        Value::String(s) => SchemaValue::text(s.clone()),
1530        Value::Array(items) => {
1531            // A JSON array of numbers (or empty) is taken as `Vector`
1532            // for the #355 query-param contract. Other arrays are
1533            // JSON values, so JSON columns can bind array payloads.
1534            if items.iter().all(|v| matches!(v, Value::Number(_))) {
1535                let floats: Vec<f32> = items
1536                    .iter()
1537                    .map(|v| v.as_f64().unwrap_or(0.0) as f32)
1538                    .collect();
1539                SchemaValue::Vector(floats)
1540            } else {
1541                SchemaValue::Json(crate::json::to_vec(v).unwrap_or_default())
1542            }
1543        }
1544        Value::Object(map) => {
1545            if map.len() == 1 {
1546                if let Some(Value::String(encoded)) = map.get("$bytes") {
1547                    if let Ok(bytes) = base64_decode(encoded) {
1548                        return SchemaValue::Blob(bytes);
1549                    }
1550                }
1551                if let Some(value) = map.get("$ts") {
1552                    if let Some(ts) = json_i64(value) {
1553                        return SchemaValue::Timestamp(ts);
1554                    }
1555                }
1556                if let Some(Value::String(value)) = map.get("$uuid") {
1557                    if let Ok(uuid) = crate::crypto::Uuid::parse_str(value) {
1558                        return SchemaValue::Uuid(*uuid.as_bytes());
1559                    }
1560                }
1561                if let Some(Value::String(value)) = map.get("$float") {
1562                    return match value.as_str() {
1563                        "NaN" => SchemaValue::Float(f64::NAN),
1564                        "Infinity" | "+Infinity" | "inf" | "+inf" => {
1565                            SchemaValue::Float(f64::INFINITY)
1566                        }
1567                        "-Infinity" | "-inf" => SchemaValue::Float(f64::NEG_INFINITY),
1568                        _ => SchemaValue::Json(crate::json::to_vec(v).unwrap_or_default()),
1569                    };
1570                }
1571            }
1572            SchemaValue::Json(crate::json::to_vec(v).unwrap_or_default())
1573        }
1574    }
1575}
1576
1577fn json_i64(value: &Value) -> Option<i64> {
1578    match value {
1579        Value::Number(n) => {
1580            if n.is_finite() && n.fract() == 0.0 && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
1581                Some(*n as i64)
1582            } else {
1583                None
1584            }
1585        }
1586        Value::String(s) => s.parse::<i64>().ok(),
1587        _ => None,
1588    }
1589}
1590
1591fn format_uuid(bytes: &[u8; 16]) -> String {
1592    format!(
1593        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1594        bytes[0],
1595        bytes[1],
1596        bytes[2],
1597        bytes[3],
1598        bytes[4],
1599        bytes[5],
1600        bytes[6],
1601        bytes[7],
1602        bytes[8],
1603        bytes[9],
1604        bytes[10],
1605        bytes[11],
1606        bytes[12],
1607        bytes[13],
1608        bytes[14],
1609        bytes[15]
1610    )
1611}
1612
1613fn base64_encode(bytes: &[u8]) -> String {
1614    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1615    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
1616    let mut chunks = bytes.chunks_exact(3);
1617    for chunk in chunks.by_ref() {
1618        let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
1619        out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
1620        out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
1621        out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
1622        out.push(TABLE[(n & 0x3f) as usize] as char);
1623    }
1624    match chunks.remainder() {
1625        [] => {}
1626        [a] => {
1627            let n = (*a as u32) << 16;
1628            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
1629            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
1630            out.push('=');
1631            out.push('=');
1632        }
1633        [a, b] => {
1634            let n = ((*a as u32) << 16) | ((*b as u32) << 8);
1635            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
1636            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
1637            out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
1638            out.push('=');
1639        }
1640        _ => unreachable!(),
1641    }
1642    out
1643}
1644
1645fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
1646    let bytes = input.as_bytes();
1647    if !bytes.len().is_multiple_of(4) {
1648        return Err("base64 length must be a multiple of 4".to_string());
1649    }
1650    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1651    for chunk in bytes.chunks_exact(4) {
1652        let pad = chunk.iter().rev().take_while(|&&b| b == b'=').count();
1653        let a = base64_value(chunk[0])?;
1654        let b = base64_value(chunk[1])?;
1655        let c = if chunk[2] == b'=' {
1656            0
1657        } else {
1658            base64_value(chunk[2])?
1659        };
1660        let d = if chunk[3] == b'=' {
1661            0
1662        } else {
1663            base64_value(chunk[3])?
1664        };
1665        let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | d as u32;
1666        out.push(((n >> 16) & 0xff) as u8);
1667        if pad < 2 {
1668            out.push(((n >> 8) & 0xff) as u8);
1669        }
1670        if pad < 1 {
1671            out.push((n & 0xff) as u8);
1672        }
1673    }
1674    Ok(out)
1675}
1676
1677fn base64_value(byte: u8) -> Result<u8, String> {
1678    match byte {
1679        b'A'..=b'Z' => Ok(byte - b'A'),
1680        b'a'..=b'z' => Ok(byte - b'a' + 26),
1681        b'0'..=b'9' => Ok(byte - b'0' + 52),
1682        b'+' => Ok(62),
1683        b'/' => Ok(63),
1684        b'=' => Ok(0),
1685        _ => Err(format!("invalid base64 character: {}", byte as char)),
1686    }
1687}
1688
1689// ---------------------------------------------------------------------------
1690// Remote dispatch (grpc://)
1691// ---------------------------------------------------------------------------
1692
1693/// Dispatch a parsed JSON-RPC call over gRPC. Mirrors `dispatch_method`
1694/// but every operation goes through the tonic client. The server's
1695/// own `RedDBRuntime` does the actual work — we are just a wire
1696/// adapter between the JSON-RPC framing the drivers speak and the
1697/// gRPC protobuf framing the server speaks.
1698fn dispatch_method_remote(
1699    client: &AsyncMutex<RedDBClient>,
1700    tokio_rt: &tokio::runtime::Runtime,
1701    method: &str,
1702    params: &Value,
1703) -> Result<Value, (&'static str, String)> {
1704    match method {
1705        "version" => Ok(Value::Object(
1706            [
1707                (
1708                    "version".to_string(),
1709                    Value::String(env!("CARGO_PKG_VERSION").to_string()),
1710                ),
1711                (
1712                    "protocol".to_string(),
1713                    Value::String(PROTOCOL_VERSION.to_string()),
1714                ),
1715            ]
1716            .into_iter()
1717            .collect(),
1718        )),
1719
1720        "health" => {
1721            let result = tokio_rt.block_on(async {
1722                let mut guard = client.lock().await;
1723                guard.health_status().await
1724            });
1725            match result {
1726                Ok(status) => Ok(Value::Object(
1727                    [
1728                        ("ok".to_string(), Value::Bool(status.healthy)),
1729                        ("state".to_string(), Value::String(status.state)),
1730                        (
1731                            "checked_at_unix_ms".to_string(),
1732                            Value::Number(status.checked_at_unix_ms as f64),
1733                        ),
1734                        (
1735                            "version".to_string(),
1736                            Value::String(env!("CARGO_PKG_VERSION").to_string()),
1737                        ),
1738                    ]
1739                    .into_iter()
1740                    .collect(),
1741                )),
1742                Err(e) => Err((error_code::INTERNAL_ERROR, e.to_string())),
1743            }
1744        }
1745
1746        "query" => {
1747            let sql = params.get("sql").and_then(Value::as_str).ok_or((
1748                error_code::INVALID_PARAMS,
1749                "missing 'sql' string".to_string(),
1750            ))?;
1751            let json_str = tokio_rt
1752                .block_on(async {
1753                    let mut guard = client.lock().await;
1754                    guard.query(sql).await
1755                })
1756                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1757            // Server returned its own QueryReply.result_json. Parse and
1758            // repackage into the stdio-protocol shape. If parsing fails,
1759            // hand the raw server JSON back under a sentinel key so the
1760            // caller still gets something useful.
1761            let parsed = json::from_str::<Value>(&json_str)
1762                .map_err(|e| (error_code::INTERNAL_ERROR, format!("bad server JSON: {e}")))?;
1763            Ok(parsed)
1764        }
1765
1766        "insert" => {
1767            let collection = params.get("collection").and_then(Value::as_str).ok_or((
1768                error_code::INVALID_PARAMS,
1769                "missing 'collection' string".to_string(),
1770            ))?;
1771            let payload = params.get("payload").ok_or((
1772                error_code::INVALID_PARAMS,
1773                "missing 'payload' object".to_string(),
1774            ))?;
1775            if payload.as_object().is_none() {
1776                return Err((
1777                    error_code::INVALID_PARAMS,
1778                    "'payload' must be a JSON object".to_string(),
1779                ));
1780            }
1781            let payload_json = payload.to_string_compact();
1782            let reply = tokio_rt
1783                .block_on(async {
1784                    let mut guard = client.lock().await;
1785                    guard.create_row_entity(collection, &payload_json).await
1786                })
1787                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1788            let mut out = json::Map::new();
1789            out.insert("affected".to_string(), Value::Number(1.0));
1790            out.insert("id".to_string(), Value::String(reply.id.to_string()));
1791            Ok(Value::Object(out))
1792        }
1793
1794        "bulk_insert" => {
1795            let collection = params.get("collection").and_then(Value::as_str).ok_or((
1796                error_code::INVALID_PARAMS,
1797                "missing 'collection' string".to_string(),
1798            ))?;
1799            let payloads = params.get("payloads").and_then(Value::as_array).ok_or((
1800                error_code::INVALID_PARAMS,
1801                "missing 'payloads' array".to_string(),
1802            ))?;
1803            let mut encoded = Vec::with_capacity(payloads.len());
1804            for entry in payloads {
1805                if entry.as_object().is_none() {
1806                    return Err((
1807                        error_code::INVALID_PARAMS,
1808                        "each payload must be a JSON object".to_string(),
1809                    ));
1810                }
1811                encoded.push(entry.to_string_compact());
1812            }
1813            let status = tokio_rt
1814                .block_on(async {
1815                    let mut guard = client.lock().await;
1816                    guard.bulk_create_rows(collection, encoded).await
1817                })
1818                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1819            Ok(Value::Object(
1820                [
1821                    ("affected".to_string(), Value::Number(status.count as f64)),
1822                    (
1823                        "ids".to_string(),
1824                        Value::Array(
1825                            status
1826                                .ids
1827                                .into_iter()
1828                                .map(|id| Value::Number(id as f64))
1829                                .collect(),
1830                        ),
1831                    ),
1832                ]
1833                .into_iter()
1834                .collect(),
1835            ))
1836        }
1837
1838        "get" => {
1839            let collection = params.get("collection").and_then(Value::as_str).ok_or((
1840                error_code::INVALID_PARAMS,
1841                "missing 'collection' string".to_string(),
1842            ))?;
1843            let id = params.get("id").and_then(Value::as_str).ok_or((
1844                error_code::INVALID_PARAMS,
1845                "missing 'id' string".to_string(),
1846            ))?;
1847            let sql = format!("SELECT * FROM {collection} WHERE rid = {id} LIMIT 1");
1848            let json_str = tokio_rt
1849                .block_on(async {
1850                    let mut guard = client.lock().await;
1851                    guard.query(&sql).await
1852                })
1853                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1854            let parsed = json::from_str::<Value>(&json_str)
1855                .map_err(|e| (error_code::INTERNAL_ERROR, format!("bad server JSON: {e}")))?;
1856            // Server response shape: {"rows":[{...}], ...}. Extract
1857            // the first row (if any) as `entity`.
1858            let entity = parsed
1859                .get("rows")
1860                .and_then(Value::as_array)
1861                .and_then(|rows| rows.first().cloned())
1862                .unwrap_or(Value::Null);
1863            Ok(Value::Object(
1864                [("entity".to_string(), entity)].into_iter().collect(),
1865            ))
1866        }
1867
1868        "delete" => {
1869            let collection = params.get("collection").and_then(Value::as_str).ok_or((
1870                error_code::INVALID_PARAMS,
1871                "missing 'collection' string".to_string(),
1872            ))?;
1873            let id = params.get("id").and_then(Value::as_str).ok_or((
1874                error_code::INVALID_PARAMS,
1875                "missing 'id' string".to_string(),
1876            ))?;
1877            let id = id.parse::<u64>().map_err(|_| {
1878                (
1879                    error_code::INVALID_PARAMS,
1880                    "id must be a numeric string".to_string(),
1881                )
1882            })?;
1883            let _reply = tokio_rt
1884                .block_on(async {
1885                    let mut guard = client.lock().await;
1886                    guard.delete_entity(collection, id).await
1887                })
1888                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1889            Ok(Value::Object(
1890                [("affected".to_string(), Value::Number(1.0))]
1891                    .into_iter()
1892                    .collect(),
1893            ))
1894        }
1895
1896        "close" => Ok(Value::Null),
1897
1898        other => Err((
1899            error_code::INVALID_REQUEST,
1900            format!("unknown method: {other}"),
1901        )),
1902    }
1903}
1904
1905#[cfg(test)]
1906mod tests {
1907    use super::*;
1908    use crate::json::json;
1909    use proptest::prelude::*;
1910
1911    fn make_runtime() -> RedDBRuntime {
1912        RedDBRuntime::in_memory().expect("in-memory runtime")
1913    }
1914
1915    fn create_graph_collection(rt: &RedDBRuntime, name: &str) {
1916        let db = rt.db();
1917        db.store()
1918            .create_collection(name)
1919            .expect("create collection");
1920        let now = std::time::SystemTime::now()
1921            .duration_since(std::time::UNIX_EPOCH)
1922            .unwrap_or_default()
1923            .as_millis();
1924        db.save_collection_contract(crate::physical::CollectionContract {
1925            name: name.to_string(),
1926            declared_model: crate::catalog::CollectionModel::Graph,
1927            schema_mode: crate::catalog::SchemaMode::Dynamic,
1928            origin: crate::physical::ContractOrigin::Explicit,
1929            version: 1,
1930            created_at_unix_ms: now,
1931            updated_at_unix_ms: now,
1932            default_ttl_ms: None,
1933            vector_dimension: None,
1934            vector_metric: None,
1935            context_index_fields: Vec::new(),
1936            declared_columns: Vec::new(),
1937            table_def: None,
1938            timestamps_enabled: false,
1939            context_index_enabled: false,
1940            metrics_raw_retention_ms: None,
1941            metrics_rollup_policies: Vec::new(),
1942            metrics_tenant_identity: None,
1943            metrics_namespace: None,
1944            append_only: false,
1945            subscriptions: Vec::new(),
1946            analytics_config: Vec::new(),
1947            session_key: None,
1948            session_gap_ms: None,
1949            retention_duration_ms: None,
1950            analytical_storage: None,
1951
1952            ai_policy: None,
1953        })
1954        .expect("save graph contract");
1955    }
1956
1957    fn handle(rt: &RedDBRuntime, line: &str) -> String {
1958        let mut session = Session::new();
1959        handle_line(&Backend::Local(rt), &mut session, line)
1960    }
1961
1962    fn query_request(id: u64, sql: &str) -> String {
1963        let mut params = json::Map::new();
1964        params.insert("sql".to_string(), Value::String(sql.to_string()));
1965
1966        let mut request = json::Map::new();
1967        request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1968        request.insert("id".to_string(), Value::Number(id as f64));
1969        request.insert("method".to_string(), Value::String("query".to_string()));
1970        request.insert("params".to_string(), Value::Object(params));
1971        Value::Object(request).to_string_compact()
1972    }
1973
1974    fn query_request_with_params(id: u64, sql: &str, binds: Vec<Value>) -> String {
1975        let mut params = json::Map::new();
1976        params.insert("sql".to_string(), Value::String(sql.to_string()));
1977        params.insert("params".to_string(), Value::Array(binds));
1978
1979        let mut request = json::Map::new();
1980        request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1981        request.insert("id".to_string(), Value::Number(id as f64));
1982        request.insert("method".to_string(), Value::String("query".to_string()));
1983        request.insert("params".to_string(), Value::Object(params));
1984        Value::Object(request).to_string_compact()
1985    }
1986
1987    /// Stateful helper: keeps the same `Session` across multiple calls so
1988    /// tests can exercise multi-step transaction flows in a single closure.
1989    fn with_session<F>(rt: &RedDBRuntime, f: F)
1990    where
1991        F: FnOnce(&dyn Fn(&str) -> String, &RedDBRuntime),
1992    {
1993        let session = std::cell::RefCell::new(Session::new());
1994        let call = |line: &str| -> String {
1995            let mut s = session.borrow_mut();
1996            handle_line(&Backend::Local(rt), &mut s, line)
1997        };
1998        f(&call, rt);
1999    }
2000
2001    fn result_rows(response: &str) -> Vec<Value> {
2002        json::from_str::<Value>(response)
2003            .expect("json response")
2004            .get("result")
2005            .and_then(|result| result.get("rows"))
2006            .and_then(Value::as_array)
2007            .map(|rows| rows.to_vec())
2008            .unwrap_or_default()
2009    }
2010
2011    fn result_name_kind(response: &str) -> Vec<(String, String)> {
2012        result_rows(response)
2013            .into_iter()
2014            .map(|row| {
2015                let object = row.as_object().expect("row object");
2016                let name = object
2017                    .get("name")
2018                    .and_then(Value::as_str)
2019                    .expect("row name")
2020                    .to_string();
2021                let kind = object
2022                    .get("kind")
2023                    .and_then(Value::as_str)
2024                    .expect("row kind")
2025                    .to_string();
2026                (name, kind)
2027            })
2028            .collect()
2029    }
2030
2031    fn json_scalar_param() -> impl Strategy<Value = Value> {
2032        prop_oneof![
2033            Just(Value::Null),
2034            any::<bool>().prop_map(Value::Bool),
2035            (-1000_i64..1000_i64).prop_map(|n| Value::Number(n as f64)),
2036            "[a-z']{0,8}".prop_map(Value::String),
2037        ]
2038    }
2039
2040    fn sql_literal_for_json(value: &Value) -> String {
2041        match value {
2042            Value::Null => "NULL".to_string(),
2043            Value::Bool(true) => "TRUE".to_string(),
2044            Value::Bool(false) => "FALSE".to_string(),
2045            Value::Number(n) => format!("{n:.0}"),
2046            Value::String(s) => format!("'{}'", s.replace('\'', "''")),
2047            _ => panic!("unsupported scalar param: {value:?}"),
2048        }
2049    }
2050
2051    #[test]
2052    fn version_method_returns_version_and_protocol() {
2053        let rt = make_runtime();
2054        let line = r#"{"jsonrpc":"2.0","id":1,"method":"version","params":{}}"#;
2055        let resp = handle(&rt, line);
2056        assert!(resp.contains("\"id\":1"));
2057        assert!(resp.contains("\"protocol\":\"1.0\""));
2058        assert!(resp.contains("\"version\""));
2059    }
2060
2061    #[test]
2062    fn health_method_returns_ok_true() {
2063        let rt = make_runtime();
2064        let resp = handle(
2065            &rt,
2066            r#"{"jsonrpc":"2.0","id":"abc","method":"health","params":{}}"#,
2067        );
2068        assert!(resp.contains("\"ok\":true"));
2069        assert!(resp.contains("\"id\":\"abc\""));
2070    }
2071
2072    #[test]
2073    fn parse_error_for_invalid_json() {
2074        let rt = make_runtime();
2075        let resp = handle(&rt, "not json {");
2076        assert!(resp.contains("\"code\":\"PARSE_ERROR\""));
2077        assert!(resp.contains("\"id\":null"));
2078    }
2079
2080    #[test]
2081    fn invalid_request_when_method_missing() {
2082        let rt = make_runtime();
2083        let resp = handle(&rt, r#"{"jsonrpc":"2.0","id":1,"params":{}}"#);
2084        assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
2085    }
2086
2087    #[test]
2088    fn unknown_method_is_invalid_request() {
2089        let rt = make_runtime();
2090        let resp = handle(
2091            &rt,
2092            r#"{"jsonrpc":"2.0","id":1,"method":"frobnicate","params":{}}"#,
2093        );
2094        assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
2095        assert!(resp.contains("frobnicate"));
2096    }
2097
2098    #[test]
2099    fn invalid_params_when_query_sql_missing() {
2100        let rt = make_runtime();
2101        let resp = handle(
2102            &rt,
2103            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{}}"#,
2104        );
2105        assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
2106    }
2107
2108    #[test]
2109    fn close_method_marks_response_for_shutdown() {
2110        let rt = make_runtime();
2111        let resp = handle(
2112            &rt,
2113            r#"{"jsonrpc":"2.0","id":1,"method":"close","params":{}}"#,
2114        );
2115        assert!(resp.contains("\"__close__\":true"));
2116    }
2117
2118    #[test]
2119    fn query_with_int_text_params_round_trips() {
2120        let rt = make_runtime();
2121        let _ = handle(
2122            &rt,
2123            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE p (id INTEGER, name TEXT)"}}"#,
2124        );
2125        let _ = handle(
2126            &rt,
2127            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (1, 'Alice')"}}"#,
2128        );
2129        let _ = handle(
2130            &rt,
2131            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (2, 'Bob')"}}"#,
2132        );
2133        let resp = handle(
2134            &rt,
2135            r#"{"jsonrpc":"2.0","id":4,"method":"query","params":{"sql":"SELECT * FROM p WHERE id = $1 AND name = $2","params":[1,"Alice"]}}"#,
2136        );
2137        assert!(resp.contains("\"Alice\""), "got: {resp}");
2138        assert!(!resp.contains("\"Bob\""), "got: {resp}");
2139    }
2140
2141    #[test]
2142    fn query_with_question_params_covers_select_insert_update_delete() {
2143        let rt = make_runtime();
2144        let create = handle(
2145            &rt,
2146            &query_request(1, "CREATE TABLE qp (id INTEGER, name TEXT)"),
2147        );
2148        assert!(!create.contains("\"error\""), "got: {create}");
2149
2150        let inserted = handle(
2151            &rt,
2152            &query_request_with_params(
2153                2,
2154                "INSERT INTO qp (id, name) VALUES (?, ?)",
2155                vec![json!(1), json!("O'Reilly")],
2156            ),
2157        );
2158        assert!(inserted.contains("\"affected\":1"), "got: {inserted}");
2159
2160        let selected = handle(
2161            &rt,
2162            &query_request_with_params(3, "SELECT name FROM qp WHERE id = ?", vec![json!(1)]),
2163        );
2164        let rows = result_rows(&selected);
2165        assert_eq!(rows.len(), 1, "got: {selected}");
2166        assert_eq!(
2167            rows[0].get("name").and_then(Value::as_str),
2168            Some("O'Reilly")
2169        );
2170
2171        let selected_numbered = handle(
2172            &rt,
2173            &query_request_with_params(
2174                4,
2175                "SELECT name FROM qp WHERE name = ?1 AND id = ?2",
2176                vec![json!("O'Reilly"), json!(1)],
2177            ),
2178        );
2179        assert_eq!(
2180            result_rows(&selected_numbered).len(),
2181            1,
2182            "got: {selected_numbered}"
2183        );
2184
2185        let updated = handle(
2186            &rt,
2187            &query_request_with_params(
2188                5,
2189                "UPDATE qp SET name = ? WHERE id = ?",
2190                vec![json!("Alice"), json!(1)],
2191            ),
2192        );
2193        assert!(updated.contains("\"affected\":1"), "got: {updated}");
2194
2195        let deleted = handle(
2196            &rt,
2197            &query_request_with_params(6, "DELETE FROM qp WHERE name = ?", vec![json!("Alice")]),
2198        );
2199        assert!(deleted.contains("\"affected\":1"), "got: {deleted}");
2200
2201        let remaining = handle(&rt, &query_request(7, "SELECT * FROM qp"));
2202        assert!(result_rows(&remaining).is_empty(), "got: {remaining}");
2203    }
2204
2205    #[test]
2206    fn query_with_params_insert_and_search_round_trip() {
2207        let rt = make_runtime();
2208        let insert = handle(
2209            &rt,
2210            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"INSERT INTO bun_embeddings VECTOR (dense, content) VALUES ($1, $2)","params":[[1.0,0.0],"bun vector"]}}"#,
2211        );
2212        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2213
2214        let search = handle(
2215            &rt,
2216            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SEARCH SIMILAR $1 COLLECTION bun_embeddings LIMIT 1","params":[[1.0,0.0]]}}"#,
2217        );
2218        assert!(search.contains("\"rows\""), "got: {search}");
2219        assert!(search.contains("\"score\":1"), "got: {search}");
2220        assert!(!search.contains("\"error\""), "got: {search}");
2221    }
2222
2223    #[test]
2224    fn query_with_question_vector_param_round_trips() {
2225        let rt = make_runtime();
2226        let insert = handle(
2227            &rt,
2228            &query_request_with_params(
2229                1,
2230                "INSERT INTO question_embeddings VECTOR (dense, content) VALUES (?, ?)",
2231                vec![json!([1.0, 0.0]), json!("question vector")],
2232            ),
2233        );
2234        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2235
2236        let search = handle(
2237            &rt,
2238            &query_request_with_params(
2239                2,
2240                "SEARCH SIMILAR ? COLLECTION question_embeddings LIMIT 1",
2241                vec![json!([1.0, 0.0])],
2242            ),
2243        );
2244        assert!(search.contains("\"rows\""), "got: {search}");
2245        assert!(search.contains("\"score\":1"), "got: {search}");
2246        assert!(!search.contains("\"error\""), "got: {search}");
2247    }
2248
2249    #[test]
2250    fn query_with_typed_json_rpc_params_round_trips() {
2251        let rt = make_runtime();
2252        let create = handle(
2253            &rt,
2254            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE value_params (ok BOOLEAN, score FLOAT, payload BLOB, body JSON, seen_at TIMESTAMP, ident UUID)"}}"#,
2255        );
2256        assert!(!create.contains("\"error\""), "got: {create}");
2257
2258        let insert = handle(
2259            &rt,
2260            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO value_params (ok, score, payload, body, seen_at, ident) VALUES ($1, $2, $3, $4, $5, $6)","params":[true,{"$float":"NaN"},{"$bytes":"3q2+7w=="},{"z":[1,{"a":true}],"a":null},{"$ts":"1700000000123456789"},{"$uuid":"00112233-4455-6677-8899-aabbccddeeff"}]}}"#,
2261        );
2262        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2263
2264        let selected = handle(
2265            &rt,
2266            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"SELECT * FROM value_params"}}"#,
2267        );
2268        assert!(selected.contains("\"ok\":true"), "got: {selected}");
2269        assert!(selected.contains("\"$float\":\"NaN\""), "got: {selected}");
2270        assert!(
2271            selected.contains("\"$bytes\":\"3q2+7w==\""),
2272            "got: {selected}"
2273        );
2274        assert!(
2275            selected.contains("\"body\":{\"a\":null,\"z\":[1,{\"a\":true}]}"),
2276            "got: {selected}"
2277        );
2278        assert!(
2279            selected.contains("\"$ts\":\"1700000000123456789\""),
2280            "got: {selected}"
2281        );
2282        assert!(
2283            selected.contains("\"$uuid\":\"00112233-4455-6677-8899-aabbccddeeff\""),
2284            "got: {selected}"
2285        );
2286    }
2287
2288    #[test]
2289    fn select_timeseries_tags_decodes_json_payload() {
2290        let rt = make_runtime();
2291        let create = handle(&rt, &query_request(1, "CREATE TIMESERIES ts1"));
2292        assert!(!create.contains("\"error\""), "got: {create}");
2293
2294        let insert = handle(
2295            &rt,
2296            &query_request(
2297                2,
2298                r#"INSERT INTO ts1 (metric, value, tags, timestamp) VALUES ('cpu', 85, '{"host":"a"}', 1000)"#,
2299            ),
2300        );
2301        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2302
2303        let selected = handle(&rt, &query_request(3, "SELECT tags FROM ts1"));
2304        assert!(!selected.contains("<json"), "got: {selected}");
2305        let response = json::from_str::<Value>(&selected).expect("response json");
2306        let tags = response
2307            .get("result")
2308            .and_then(|result| result.get("rows"))
2309            .and_then(Value::as_array)
2310            .and_then(|rows| rows.first())
2311            .and_then(|row| row.get("tags"))
2312            .expect("tags field");
2313        assert_eq!(tags, &json!({"host": "a"}));
2314    }
2315
2316    #[test]
2317    fn select_table_json_column_round_trips_after_single_parse() {
2318        let rt = make_runtime();
2319        let create = handle(&rt, &query_request(1, "CREATE TABLE docs (payload JSON)"));
2320        assert!(!create.contains("\"error\""), "got: {create}");
2321
2322        let original = r#"{"nested":{"items":[1,true,"x"],"object":{"k":"v"}}}"#;
2323        let insert_sql = format!("INSERT INTO docs (payload) VALUES ({original})");
2324        let insert = handle(&rt, &query_request(2, &insert_sql));
2325        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2326
2327        let selected = handle(&rt, &query_request(3, "SELECT payload FROM docs"));
2328        assert!(!selected.contains("<json"), "got: {selected}");
2329        let response = json::from_str::<Value>(&selected).expect("response json");
2330        let payload = response
2331            .get("result")
2332            .and_then(|result| result.get("rows"))
2333            .and_then(Value::as_array)
2334            .and_then(|rows| rows.first())
2335            .and_then(|row| row.get("payload"))
2336            .expect("payload field");
2337        let expected = json::from_str::<Value>(original).expect("expected json");
2338        assert_eq!(payload, &expected);
2339
2340        let payload_text = payload.to_string_compact();
2341        assert_eq!(
2342            json::from_str::<Value>(&payload_text).expect("single parse"),
2343            expected
2344        );
2345    }
2346
2347    #[test]
2348    fn select_json_corruption_falls_back_to_code_and_hex() {
2349        use crate::storage::query::unified::UnifiedResult;
2350
2351        let mut result = UnifiedResult::with_columns(vec!["payload".into()]);
2352        let mut record = UnifiedRecord::new();
2353        record.set("payload", SchemaValue::Json(b"{not json".to_vec()));
2354        result.push(record);
2355
2356        let json = query_result_to_json(&RuntimeQueryResult {
2357            query: "SELECT payload FROM docs".to_string(),
2358            mode: crate::storage::query::modes::QueryMode::Sql,
2359            statement: "select",
2360            engine: "runtime-table",
2361            result,
2362            affected_rows: 0,
2363            statement_type: "select",
2364            bookmark: None,
2365        });
2366
2367        let payload = json
2368            .get("rows")
2369            .and_then(Value::as_array)
2370            .and_then(|rows| rows.first())
2371            .and_then(|row| row.get("payload"))
2372            .expect("payload field");
2373        assert_eq!(
2374            payload.get("code").and_then(Value::as_str),
2375            Some("INVALID_JSON")
2376        );
2377        assert_eq!(
2378            payload.get("hex").and_then(Value::as_str),
2379            Some("7b6e6f74206a736f6e")
2380        );
2381    }
2382
2383    #[test]
2384    fn json_value_to_schema_value_decodes_typed_envelopes() {
2385        let SchemaValue::Blob(bytes) = json_value_to_schema_value(&json!({ "$bytes": "AAECAw==" }))
2386        else {
2387            panic!("expected blob");
2388        };
2389        assert_eq!(bytes, vec![0, 1, 2, 3]);
2390
2391        assert_eq!(
2392            json_value_to_schema_value(&json!({ "$ts": "9223372036854775807" })),
2393            SchemaValue::Timestamp(i64::MAX)
2394        );
2395
2396        let SchemaValue::Uuid(bytes) = json_value_to_schema_value(&json!({
2397            "$uuid": "00112233-4455-6677-8899-aabbccddeeff"
2398        })) else {
2399            panic!("expected uuid");
2400        };
2401        assert_eq!(
2402            bytes,
2403            [
2404                0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
2405                0xee, 0xff
2406            ]
2407        );
2408
2409        let SchemaValue::Float(value) =
2410            json_value_to_schema_value(&json!({ "$float": "-Infinity" }))
2411        else {
2412            panic!("expected float");
2413        };
2414        assert!(value.is_infinite() && value.is_sign_negative());
2415    }
2416
2417    #[test]
2418    fn query_with_params_arity_mismatch_rejected() {
2419        let rt = make_runtime();
2420        let _ = handle(
2421            &rt,
2422            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pa (id INTEGER)"}}"#,
2423        );
2424        let resp = handle(
2425            &rt,
2426            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pa WHERE id = $1","params":[1,2]}}"#,
2427        );
2428        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2429    }
2430
2431    #[test]
2432    fn query_with_question_params_arity_mismatch_rejected() {
2433        let rt = make_runtime();
2434        let _ = handle(&rt, &query_request(1, "CREATE TABLE qpa (id INTEGER)"));
2435        let resp = handle(
2436            &rt,
2437            &query_request_with_params(
2438                2,
2439                "SELECT * FROM qpa WHERE id = ?",
2440                vec![json!(1), json!(2)],
2441            ),
2442        );
2443        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2444        assert!(resp.contains("SQL expects 1, got 2"), "got: {resp}");
2445    }
2446
2447    #[test]
2448    fn query_with_params_gap_rejected() {
2449        let rt = make_runtime();
2450        let _ = handle(
2451            &rt,
2452            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pg (a INTEGER, b INTEGER)"}}"#,
2453        );
2454        let resp = handle(
2455            &rt,
2456            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pg WHERE a = $1 AND b = $3","params":[1,2,3]}}"#,
2457        );
2458        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2459    }
2460
2461    #[test]
2462    fn query_with_question_numbered_gap_rejected() {
2463        let rt = make_runtime();
2464        let _ = handle(&rt, &query_request(1, "CREATE TABLE qpg (id INTEGER)"));
2465        let resp = handle(
2466            &rt,
2467            &query_request_with_params(
2468                2,
2469                "SELECT * FROM qpg WHERE id = ?2",
2470                vec![json!(1), json!(2)],
2471            ),
2472        );
2473        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2474        assert!(resp.contains("parameter $`1` is missing"), "got: {resp}");
2475    }
2476
2477    #[test]
2478    fn query_with_question_params_type_mismatch_names_slot() {
2479        let rt = make_runtime();
2480        let _ = handle(&rt, &query_request(1, "CREATE TABLE qpt (id INTEGER)"));
2481        let resp = handle(
2482            &rt,
2483            &query_request_with_params(
2484                2,
2485                "INSERT INTO qpt (id) VALUES (?)",
2486                vec![json!("not-an-integer")],
2487            ),
2488        );
2489        assert!(resp.contains("\"QUERY_ERROR\""), "got: {resp}");
2490        assert!(resp.contains("id"), "got: {resp}");
2491        assert!(resp.contains("integer"), "got: {resp}");
2492    }
2493
2494    #[test]
2495    fn query_select_one_returns_rows() {
2496        let rt = make_runtime();
2497        let resp = handle(
2498            &rt,
2499            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT 1 AS one"}}"#,
2500        );
2501        assert!(resp.contains("\"result\""));
2502        assert!(!resp.contains("\"error\""));
2503    }
2504
2505    #[test]
2506    fn ask_query_result_uses_canonical_envelope() {
2507        use crate::storage::query::unified::UnifiedResult;
2508
2509        let mut result = UnifiedResult::with_columns(vec![
2510            "answer".into(),
2511            "provider".into(),
2512            "model".into(),
2513            "prompt_tokens".into(),
2514            "completion_tokens".into(),
2515            "sources_count".into(),
2516            "sources_flat".into(),
2517            "citations".into(),
2518            "validation".into(),
2519        ]);
2520        let mut record = UnifiedRecord::new();
2521        record.set("answer", SchemaValue::text("Deploy failed [^1]."));
2522        record.set("provider", SchemaValue::text("openai"));
2523        record.set("model", SchemaValue::text("gpt-4o-mini"));
2524        record.set("prompt_tokens", SchemaValue::Integer(11));
2525        record.set("completion_tokens", SchemaValue::Integer(7));
2526        record.set(
2527            "sources_flat",
2528            SchemaValue::Json(
2529                br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
2530            ),
2531        );
2532        record.set(
2533            "citations",
2534            SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
2535        );
2536        record.set(
2537            "validation",
2538            SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
2539        );
2540        result.push(record);
2541
2542        let json = query_result_to_json(&RuntimeQueryResult {
2543            query: "ASK 'why did deploy fail?'".to_string(),
2544            mode: crate::storage::query::modes::QueryMode::Sql,
2545            statement: "ask",
2546            engine: "runtime-ai",
2547            result,
2548            affected_rows: 0,
2549            statement_type: "select",
2550            bookmark: None,
2551        });
2552
2553        assert_eq!(
2554            json.get("answer").and_then(Value::as_str),
2555            Some("Deploy failed [^1].")
2556        );
2557        assert_eq!(json.get("cache_hit").and_then(Value::as_bool), Some(false));
2558        assert_eq!(json.get("cost_usd").and_then(Value::as_f64), Some(0.0));
2559        assert_eq!(json.get("mode").and_then(Value::as_str), Some("strict"));
2560        assert_eq!(json.get("retry_count").and_then(Value::as_u64), Some(0));
2561        assert!(
2562            json.get("rows").is_none(),
2563            "ASK envelope must not be row-wrapped: {json}"
2564        );
2565        assert!(
2566            json.get("sources_flat")
2567                .and_then(Value::as_array)
2568                .is_some_and(|sources| sources.len() == 1
2569                    && sources[0].get("payload").and_then(Value::as_str).is_some()),
2570            "sources_flat must be a parsed array: {json}"
2571        );
2572        assert!(
2573            json.get("citations")
2574                .and_then(Value::as_array)
2575                .is_some_and(|citations| citations.len() == 1),
2576            "citations must be a parsed array: {json}"
2577        );
2578        assert_eq!(
2579            json.get("validation")
2580                .and_then(|v| v.get("ok"))
2581                .and_then(Value::as_bool),
2582            Some(true)
2583        );
2584    }
2585
2586    // -----------------------------------------------------------------
2587    // Transaction tests
2588    // -----------------------------------------------------------------
2589
2590    #[test]
2591    fn tx_begin_returns_tx_id_and_isolation() {
2592        let rt = make_runtime();
2593        with_session(&rt, |call, _| {
2594            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2595            assert!(resp.contains("\"tx_id\":1"));
2596            assert!(resp.contains("\"isolation\":\"read_committed_deferred\""));
2597            assert!(!resp.contains("\"error\""));
2598        });
2599    }
2600
2601    #[test]
2602    fn tx_begin_twice_returns_already_open() {
2603        let rt = make_runtime();
2604        with_session(&rt, |call, _| {
2605            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2606            let resp = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
2607            assert!(resp.contains("\"code\":\"TX_ALREADY_OPEN\""));
2608        });
2609    }
2610
2611    #[test]
2612    fn tx_commit_without_begin_returns_no_tx_open() {
2613        let rt = make_runtime();
2614        with_session(&rt, |call, _| {
2615            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.commit","params":null}"#);
2616            assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2617        });
2618    }
2619
2620    #[test]
2621    fn tx_rollback_without_begin_returns_no_tx_open() {
2622        let rt = make_runtime();
2623        with_session(&rt, |call, _| {
2624            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.rollback","params":null}"#);
2625            assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2626        });
2627    }
2628
2629    #[test]
2630    fn insert_inside_tx_returns_pending_envelope() {
2631        let rt = make_runtime();
2632        // Create the collection first (outside any tx).
2633        let _ = handle(
2634            &rt,
2635            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE users (name TEXT)"}}"#,
2636        );
2637        with_session(&rt, |call, _| {
2638            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2639            let resp = call(
2640                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"users","payload":{"name":"alice"}}}"#,
2641            );
2642            assert!(resp.contains("\"pending\":true"));
2643            assert!(resp.contains("\"tx_id\":1"));
2644            assert!(resp.contains("\"affected\":0"));
2645        });
2646    }
2647
2648    #[test]
2649    fn begin_insert_rollback_does_not_persist() {
2650        let rt = make_runtime();
2651        let _ = handle(
2652            &rt,
2653            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u (name TEXT)"}}"#,
2654        );
2655        with_session(&rt, |call, _| {
2656            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2657            let _ = call(
2658                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u","payload":{"name":"ghost"}}}"#,
2659            );
2660            let rollback = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
2661            assert!(rollback.contains("\"ops_discarded\":1"));
2662            assert!(rollback.contains("\"tx_id\":1"));
2663        });
2664        // After rollback, the row must not be visible to a fresh query.
2665        let resp = handle(
2666            &rt,
2667            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u"}}"#,
2668        );
2669        assert!(!resp.contains("\"ghost\""));
2670    }
2671
2672    #[test]
2673    fn begin_insert_commit_persists() {
2674        let rt = make_runtime();
2675        let _ = handle(
2676            &rt,
2677            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u2 (name TEXT)"}}"#,
2678        );
2679        with_session(&rt, |call, _| {
2680            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2681            let _ = call(
2682                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u2","payload":{"name":"alice"}}}"#,
2683            );
2684            let _ = call(
2685                r#"{"jsonrpc":"2.0","id":3,"method":"insert","params":{"collection":"u2","payload":{"name":"bob"}}}"#,
2686            );
2687            let commit = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.commit","params":null}"#);
2688            assert!(commit.contains("\"ops_replayed\":2"));
2689            assert!(!commit.contains("\"error\""));
2690        });
2691        let resp = handle(
2692            &rt,
2693            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u2"}}"#,
2694        );
2695        assert!(resp.contains("\"alice\""));
2696        assert!(resp.contains("\"bob\""));
2697    }
2698
2699    #[test]
2700    fn bulk_insert_inside_tx_buffers_everything() {
2701        let rt = make_runtime();
2702        let _ = handle(
2703            &rt,
2704            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u3 (name TEXT)"}}"#,
2705        );
2706        with_session(&rt, |call, _| {
2707            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2708            let resp = call(
2709                r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"u3","payloads":[{"name":"a"},{"name":"b"},{"name":"c"}]}}"#,
2710            );
2711            assert!(resp.contains("\"buffered\":3"));
2712            assert!(resp.contains("\"pending\":true"));
2713            assert!(resp.contains("\"affected\":0"));
2714
2715            let commit = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2716            assert!(commit.contains("\"ops_replayed\":3"));
2717        });
2718    }
2719
2720    #[test]
2721    fn bulk_insert_chunks_at_internal_500_row_limit() {
2722        assert_eq!(bulk_insert_chunk_count(0), 0);
2723        assert_eq!(bulk_insert_chunk_count(1), 1);
2724        assert_eq!(bulk_insert_chunk_count(500), 1);
2725        assert_eq!(bulk_insert_chunk_count(501), 2);
2726        assert_eq!(bulk_insert_chunk_count(1000), 2);
2727        assert_eq!(bulk_insert_chunk_count(1001), 3);
2728    }
2729
2730    proptest! {
2731        #![proptest_config(ProptestConfig {
2732            cases: 12,
2733            ..ProptestConfig::default()
2734        })]
2735
2736        #[test]
2737        fn bulk_insert_matches_sequential_insert_state(
2738            names in proptest::collection::vec("[a-z]{1,8}", 1usize..20)
2739        ) {
2740            let rt = make_runtime();
2741            let payloads = names
2742                .iter()
2743                .map(|name| format!(r#"{{"name":"{name}","kind":"bulk"}}"#))
2744                .collect::<Vec<_>>();
2745            let payload_array = payloads.join(",");
2746
2747            let bulk = handle(
2748                &rt,
2749                &format!(
2750                    r#"{{"jsonrpc":"2.0","id":1,"method":"bulk_insert","params":{{"collection":"bulk_prop","payloads":[{payload_array}]}}}}"#
2751                ),
2752            );
2753            let bulk_result = json::from_str::<Value>(&bulk).expect("bulk json");
2754            let bulk_ids = bulk_result
2755                .get("result")
2756                .and_then(|result| result.get("ids"))
2757                .and_then(Value::as_array)
2758                .expect("bulk ids");
2759            prop_assert_eq!(bulk_ids.len(), names.len());
2760
2761            for (index, payload) in payloads.iter().enumerate() {
2762                let insert = handle(
2763                    &rt,
2764                    &format!(
2765                        r#"{{"jsonrpc":"2.0","id":{},"method":"insert","params":{{"collection":"seq_prop","payload":{payload}}}}}"#,
2766                        index + 10
2767                    ),
2768                );
2769                let insert_result = json::from_str::<Value>(&insert).expect("insert json");
2770                prop_assert!(
2771                    insert_result
2772                        .get("result")
2773                        .and_then(|result| result.get("id"))
2774                        .is_some(),
2775                    "insert response missing id: {insert}"
2776                );
2777            }
2778
2779            let bulk_rows = result_name_kind(&handle(
2780                &rt,
2781                r#"{"jsonrpc":"2.0","id":99,"method":"query","params":{"sql":"SELECT name, kind FROM bulk_prop ORDER BY rid"}}"#,
2782            ));
2783            let seq_rows = result_name_kind(&handle(
2784                &rt,
2785                r#"{"jsonrpc":"2.0","id":100,"method":"query","params":{"sql":"SELECT name, kind FROM seq_prop ORDER BY rid"}}"#,
2786            ));
2787            prop_assert_eq!(bulk_rows, seq_rows);
2788        }
2789
2790        #[test]
2791        fn question_param_select_matches_inlined_literal(value in json_scalar_param()) {
2792            let rt = make_runtime();
2793            let bound = handle(
2794                &rt,
2795                &query_request_with_params(1, "SELECT ? AS v", vec![value.clone()]),
2796            );
2797            let inline_sql = format!("SELECT {} AS v", sql_literal_for_json(&value));
2798            let inlined = handle(&rt, &query_request(2, &inline_sql));
2799            prop_assert_eq!(
2800                result_rows(&bound),
2801                result_rows(&inlined),
2802                "bound={}, inlined={}",
2803                bound,
2804                inlined
2805            );
2806        }
2807    }
2808
2809    #[test]
2810    fn bulk_insert_graph_nodes_accepts_flat_rows_and_returns_ids() {
2811        let rt = make_runtime();
2812        create_graph_collection(&rt, "social");
2813
2814        let resp = handle(
2815            &rt,
2816            r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"social","payloads":[{"label":"User","name":"alice"},{"label":"User","name":"bob"}]}}"#,
2817        );
2818        let envelope: Value = json::from_str(&resp).expect("json response");
2819        let result = envelope.get("result").expect("result");
2820        assert_eq!(result.get("affected").and_then(Value::as_u64), Some(2));
2821        assert_eq!(
2822            result
2823                .get("ids")
2824                .and_then(Value::as_array)
2825                .map(|ids| ids.len()),
2826            Some(2)
2827        );
2828
2829        let query = handle(
2830            &rt,
2831            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"MATCH (n:User) RETURN n.name"}}"#,
2832        );
2833        assert!(query.contains("\"alice\""), "got: {query}");
2834        assert!(query.contains("\"bob\""), "got: {query}");
2835    }
2836
2837    #[test]
2838    fn bulk_insert_graph_edges_accepts_flat_rows_and_returns_ids() {
2839        let rt = make_runtime();
2840        create_graph_collection(&rt, "network");
2841        let nodes = handle(
2842            &rt,
2843            r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"network","payloads":[{"label":"Host","name":"app"},{"label":"Host","name":"db"}]}}"#,
2844        );
2845        let envelope: Value = json::from_str(&nodes).expect("node response");
2846        let ids = envelope
2847            .get("result")
2848            .and_then(|r| r.get("ids"))
2849            .and_then(Value::as_array)
2850            .expect("node ids");
2851        let from = ids[0].as_u64().expect("from id");
2852        let to = ids[1].as_u64().expect("to id");
2853
2854        let resp = handle(
2855            &rt,
2856            &format!(
2857                r#"{{"jsonrpc":"2.0","id":3,"method":"bulk_insert","params":{{"collection":"network","payloads":[{{"label":"connects","from":{from},"to":{to},"weight":0.5,"role":"primary"}}]}}}}"#
2858            ),
2859        );
2860        let envelope: Value = json::from_str(&resp).expect("edge response");
2861        let result = envelope.get("result").expect("result");
2862        assert_eq!(result.get("affected").and_then(Value::as_u64), Some(1));
2863        assert_eq!(
2864            result
2865                .get("ids")
2866                .and_then(Value::as_array)
2867                .map(|ids| ids.len()),
2868            Some(1)
2869        );
2870    }
2871
2872    #[test]
2873    fn delete_inside_tx_is_buffered() {
2874        let rt = make_runtime();
2875        // Seed two rows outside any tx.
2876        let _ = handle(
2877            &rt,
2878            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u4 (name TEXT)"}}"#,
2879        );
2880        let _ = handle(
2881            &rt,
2882            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO u4 (name) VALUES ('keep')"}}"#,
2883        );
2884        with_session(&rt, |call, _| {
2885            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2886            let resp = call(
2887                r#"{"jsonrpc":"2.0","id":2,"method":"delete","params":{"collection":"u4","id":"1"}}"#,
2888            );
2889            assert!(resp.contains("\"pending\":true"));
2890            let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
2891        });
2892        // Row should still be present after rollback of the delete.
2893        let resp = handle(
2894            &rt,
2895            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u4"}}"#,
2896        );
2897        assert!(resp.contains("\"keep\""));
2898    }
2899
2900    #[test]
2901    fn close_with_open_tx_auto_rollbacks() {
2902        let rt = make_runtime();
2903        let _ = handle(
2904            &rt,
2905            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u5 (name TEXT)"}}"#,
2906        );
2907        with_session(&rt, |call, _| {
2908            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2909            let _ = call(
2910                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u5","payload":{"name":"ghost"}}}"#,
2911            );
2912            let close = call(r#"{"jsonrpc":"2.0","id":3,"method":"close","params":null}"#);
2913            assert!(close.contains("\"__close__\":true"));
2914            assert!(!close.contains("\"error\""));
2915        });
2916        let resp = handle(
2917            &rt,
2918            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u5"}}"#,
2919        );
2920        assert!(!resp.contains("\"ghost\""));
2921    }
2922
2923    // -----------------------------------------------------------------
2924    // Cursor streaming tests
2925    // -----------------------------------------------------------------
2926
2927    fn seed_numbers_table(rt: &RedDBRuntime, table: &str, count: u32) {
2928        let _ = handle(
2929            rt,
2930            &format!(
2931                r#"{{"jsonrpc":"2.0","id":1,"method":"query","params":{{"sql":"CREATE TABLE {table} (n INTEGER)"}}}}"#,
2932            ),
2933        );
2934        for i in 0..count {
2935            let _ = handle(
2936                rt,
2937                &format!(
2938                    r#"{{"jsonrpc":"2.0","id":2,"method":"query","params":{{"sql":"INSERT INTO {table} (n) VALUES ({i})"}}}}"#,
2939                ),
2940            );
2941        }
2942    }
2943
2944    #[test]
2945    fn cursor_open_returns_id_columns_and_total() {
2946        let rt = make_runtime();
2947        seed_numbers_table(&rt, "nums1", 3);
2948        with_session(&rt, |call, _| {
2949            let resp = call(
2950                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums1"}}"#,
2951            );
2952            assert!(resp.contains("\"cursor_id\":1"));
2953            assert!(resp.contains("\"total_rows\":3"));
2954            assert!(resp.contains("\"columns\""));
2955            assert!(!resp.contains("\"error\""));
2956        });
2957    }
2958
2959    #[test]
2960    fn cursor_next_chunks_rows_and_signals_done() {
2961        let rt = make_runtime();
2962        seed_numbers_table(&rt, "nums2", 5);
2963        with_session(&rt, |call, _| {
2964            let _ = call(
2965                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums2"}}"#,
2966            );
2967            let first = call(
2968                r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2969            );
2970            assert!(first.contains("\"done\":false"));
2971            assert!(first.contains("\"remaining\":3"));
2972
2973            let second = call(
2974                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2975            );
2976            assert!(second.contains("\"done\":false"));
2977            assert!(second.contains("\"remaining\":1"));
2978
2979            let third = call(
2980                r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2981            );
2982            assert!(third.contains("\"done\":true"));
2983            assert!(third.contains("\"remaining\":0"));
2984        });
2985    }
2986
2987    #[test]
2988    fn cursor_auto_drops_when_exhausted() {
2989        let rt = make_runtime();
2990        seed_numbers_table(&rt, "nums3", 2);
2991        with_session(&rt, |call, _| {
2992            let _ = call(
2993                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums3"}}"#,
2994            );
2995            let _ = call(
2996                r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
2997            );
2998            // Cursor was auto-dropped after done=true; subsequent next
2999            // must error with CURSOR_NOT_FOUND.
3000            let resp = call(
3001                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
3002            );
3003            assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
3004        });
3005    }
3006
3007    #[test]
3008    fn cursor_close_removes_it() {
3009        let rt = make_runtime();
3010        seed_numbers_table(&rt, "nums4", 3);
3011        with_session(&rt, |call, _| {
3012            let _ = call(
3013                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums4"}}"#,
3014            );
3015            let close =
3016                call(r#"{"jsonrpc":"2.0","id":2,"method":"query.close","params":{"cursor_id":1}}"#);
3017            assert!(close.contains("\"closed\":true"));
3018            let after = call(
3019                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
3020            );
3021            assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
3022        });
3023    }
3024
3025    #[test]
3026    fn cursor_close_unknown_errors() {
3027        let rt = make_runtime();
3028        with_session(&rt, |call, _| {
3029            let resp = call(
3030                r#"{"jsonrpc":"2.0","id":1,"method":"query.close","params":{"cursor_id":9999}}"#,
3031            );
3032            assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
3033        });
3034    }
3035
3036    #[test]
3037    fn cursor_next_without_cursor_id_errors() {
3038        let rt = make_runtime();
3039        with_session(&rt, |call, _| {
3040            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"query.next","params":{}}"#);
3041            assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
3042        });
3043    }
3044
3045    #[test]
3046    fn cursor_default_batch_size_returns_all_when_smaller_than_default() {
3047        let rt = make_runtime();
3048        seed_numbers_table(&rt, "nums5", 7);
3049        with_session(&rt, |call, _| {
3050            let _ = call(
3051                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums5"}}"#,
3052            );
3053            // No batch_size → default 100, table has 7 rows, all in one call.
3054            let resp =
3055                call(r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1}}"#);
3056            assert!(resp.contains("\"done\":true"));
3057            assert!(resp.contains("\"remaining\":0"));
3058        });
3059    }
3060
3061    #[test]
3062    fn close_method_drops_open_cursors() {
3063        let rt = make_runtime();
3064        seed_numbers_table(&rt, "nums6", 3);
3065        // Single session: open a cursor, call close, verify cursor is gone by reopening
3066        // fresh session and attempting to use cursor_id 1.
3067        with_session(&rt, |call, _| {
3068            let _ = call(
3069                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums6"}}"#,
3070            );
3071            let close = call(r#"{"jsonrpc":"2.0","id":2,"method":"close","params":null}"#);
3072            assert!(close.contains("\"__close__\":true"));
3073            // Cursor must be gone after close within the same session.
3074            let after = call(
3075                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
3076            );
3077            assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
3078        });
3079    }
3080
3081    #[test]
3082    fn cursor_independent_of_transaction_state() {
3083        let rt = make_runtime();
3084        seed_numbers_table(&rt, "nums7", 4);
3085        with_session(&rt, |call, _| {
3086            // Open cursor, begin tx, commit tx — cursor survives.
3087            let _ = call(
3088                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums7"}}"#,
3089            );
3090            let _ = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
3091            let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
3092            let resp = call(
3093                r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
3094            );
3095            assert!(resp.contains("\"done\":true"));
3096            assert!(!resp.contains("\"error\""));
3097        });
3098    }
3099
3100    #[test]
3101    fn second_tx_after_commit_gets_fresh_id() {
3102        let rt = make_runtime();
3103        let _ = handle(
3104            &rt,
3105            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u6 (name TEXT)"}}"#,
3106        );
3107        with_session(&rt, |call, _| {
3108            let first = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
3109            assert!(first.contains("\"tx_id\":1"));
3110            let _ = call(
3111                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u6","payload":{"name":"x"}}}"#,
3112            );
3113            let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
3114
3115            let second = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.begin","params":null}"#);
3116            assert!(second.contains("\"tx_id\":2"));
3117            let _ = call(r#"{"jsonrpc":"2.0","id":5,"method":"tx.rollback","params":null}"#);
3118        });
3119    }
3120
3121    #[test]
3122    fn prepare_and_execute_prepared_statement() {
3123        let rt = make_runtime();
3124        // Create table + insert a row
3125        let _ = handle(
3126            &rt,
3127            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE ps_test (n INTEGER)"}}"#,
3128        );
3129        let _ = handle(
3130            &rt,
3131            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO ps_test (n) VALUES (42)"}}"#,
3132        );
3133
3134        with_session(&rt, |call, _| {
3135            // Prepare a parameterized SELECT.
3136            let prep = call(
3137                r#"{"jsonrpc":"2.0","id":3,"method":"prepare","params":{"sql":"SELECT n FROM ps_test WHERE n = 42"}}"#,
3138            );
3139            assert!(prep.contains("\"prepared_id\""), "prepare response: {prep}");
3140
3141            // Extract the prepared_id.
3142            let id: u64 = {
3143                let v: crate::json::Value = crate::json::from_str(&prep).expect("json");
3144                let result = v.get("result").expect("result");
3145                result
3146                    .get("prepared_id")
3147                    .and_then(|n| n.as_f64())
3148                    .expect("prepared_id") as u64
3149            };
3150
3151            // Execute with the bind value for the parameterized literal.
3152            let exec = call(&format!(
3153                r#"{{"jsonrpc":"2.0","id":4,"method":"execute_prepared","params":{{"prepared_id":{id},"binds":[42]}}}}"#
3154            ));
3155            // Response uses "rows" key (see query_result_to_json).
3156            assert!(
3157                exec.contains("\"rows\""),
3158                "execute_prepared response: {exec}"
3159            );
3160            assert!(exec.contains("42"), "expected row with n=42 in: {exec}");
3161        });
3162    }
3163}