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