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                        .and_then(|arr| {
703                            arr.iter()
704                                .map(try_json_value_to_schema_value)
705                                .collect::<Result<Vec<_>, _>>()
706                                .map_err(|message| (error_code::INVALID_PARAMS, message))
707                        })
708                })
709                .transpose()?;
710
711            if let Some(binds) = bind_values {
712                let qr = runtime
713                    .execute_query_with_params(sql, &binds)
714                    .map_err(|e| {
715                        if matches!(e, crate::api::RedDBError::Validation { .. }) {
716                            (error_code::INVALID_PARAMS, e.to_string())
717                        } else {
718                            (error_code::QUERY_ERROR, e.to_string())
719                        }
720                    })?;
721                return Ok(query_result_to_json(&qr));
722            }
723
724            let qr = runtime
725                .execute_query(sql)
726                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
727            Ok(query_result_to_json(&qr))
728        }
729
730        // ── Prepared statements ──────────────────────────────────────────────
731        //
732        // `prepare` parses the SQL once, extracts a parameterized shape, and
733        // returns a `prepared_id` the client can reuse. `execute_prepared` takes
734        // that id plus JSON-encoded bind values and runs the plan without parsing.
735        //
736        // This mirrors the PostgreSQL extended-query protocol semantics and is the
737        // server-side half of the client driver's `PreparedStatement` abstraction.
738        "prepare" => {
739            use crate::storage::query::modes::parse_multi;
740            use crate::storage::query::planner::shape::parameterize_query_expr;
741
742            let sql = params.get("sql").and_then(Value::as_str).ok_or((
743                error_code::INVALID_PARAMS,
744                "missing 'sql' string".to_string(),
745            ))?;
746            let parsed = parse_multi(sql).map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
747            let (shape, parameter_count) = if let Some(prepared) = parameterize_query_expr(&parsed)
748            {
749                (prepared.shape, prepared.parameter_count)
750            } else {
751                (parsed, 0)
752            };
753            let id = session.next_prepared_id;
754            session.next_prepared_id = session.next_prepared_id.saturating_add(1);
755            session.prepared.insert(
756                id,
757                StdioPreparedStatement {
758                    shape,
759                    parameter_count,
760                },
761            );
762            Ok(Value::Object(
763                [
764                    ("prepared_id".to_string(), Value::Number(id as f64)),
765                    (
766                        "parameter_count".to_string(),
767                        Value::Number(parameter_count as f64),
768                    ),
769                ]
770                .into_iter()
771                .collect(),
772            ))
773        }
774
775        "execute_prepared" => {
776            use crate::storage::query::planner::shape::bind_parameterized_query;
777            use crate::storage::schema::Value as SV;
778
779            let id = params
780                .get("prepared_id")
781                .and_then(Value::as_f64)
782                .map(|n| n as u64)
783                .ok_or((
784                    error_code::INVALID_PARAMS,
785                    "missing 'prepared_id'".to_string(),
786                ))?;
787
788            let stmt = session.prepared.get(&id).ok_or((
789                error_code::QUERY_ERROR,
790                format!("no prepared statement with id {id}"),
791            ))?;
792
793            // Parse bind values from JSON array of JSON-encoded literals.
794            let binds_json: Vec<Value> = params
795                .get("binds")
796                .and_then(Value::as_array)
797                .map(|a| a.to_vec())
798                .unwrap_or_default();
799            if binds_json.len() != stmt.parameter_count {
800                return Err((
801                    error_code::INVALID_PARAMS,
802                    format!(
803                        "expected {} bind values, got {}",
804                        stmt.parameter_count,
805                        binds_json.len()
806                    ),
807                ));
808            }
809
810            // Convert JSON bind values to SchemaValue.
811            let binds: Vec<SV> = binds_json
812                .iter()
813                .map(try_json_value_to_schema_value)
814                .collect::<Result<Vec<_>, _>>()
815                .map_err(|message| (error_code::INVALID_PARAMS, message))?;
816
817            // Bind literals into the parameterized shape.
818            let expr = if stmt.parameter_count == 0 {
819                stmt.shape.clone()
820            } else {
821                bind_parameterized_query(&stmt.shape, &binds, stmt.parameter_count)
822                    .ok_or((error_code::QUERY_ERROR, "bind failed".to_string()))?
823            };
824
825            let qr = runtime
826                .execute_query_expr(expr)
827                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
828            Ok(query_result_to_json(&qr))
829        }
830
831        "insert" => {
832            let collection = params.get("collection").and_then(Value::as_str).ok_or((
833                error_code::INVALID_PARAMS,
834                "missing 'collection' string".to_string(),
835            ))?;
836            let payload = params.get("payload").ok_or((
837                error_code::INVALID_PARAMS,
838                "missing 'payload' object".to_string(),
839            ))?;
840            let payload_obj = payload.as_object().ok_or((
841                error_code::INVALID_PARAMS,
842                "'payload' must be a JSON object".to_string(),
843            ))?;
844            if let Some(tx) = session.current_tx_mut() {
845                let sql = build_insert_sql(collection, payload_obj.iter());
846                tx.write_set.push(PendingSql::Insert(sql));
847                return Ok(pending_tx_response(tx.tx_id));
848            }
849
850            let output = runtime
851                .create_row(flat_payload_to_row_input(collection, payload_obj))
852                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
853            let mut out = json::Map::new();
854            out.insert("affected".to_string(), Value::Number(1.0));
855            out.insert("id".to_string(), Value::String(output.id.raw().to_string()));
856            Ok(Value::Object(out))
857        }
858
859        "bulk_insert" => {
860            let collection = params.get("collection").and_then(Value::as_str).ok_or((
861                error_code::INVALID_PARAMS,
862                "missing 'collection' string".to_string(),
863            ))?;
864            let payloads = params.get("payloads").and_then(Value::as_array).ok_or((
865                error_code::INVALID_PARAMS,
866                "missing 'payloads' array".to_string(),
867            ))?;
868
869            let mut objects = Vec::with_capacity(payloads.len());
870            for entry in payloads {
871                objects.push(entry.as_object().ok_or((
872                    error_code::INVALID_PARAMS,
873                    "each payload must be a JSON object".to_string(),
874                ))?);
875            }
876
877            if let Some(tx) = session.current_tx_mut() {
878                let mut buffered: u64 = 0;
879                for obj in &objects {
880                    let sql = build_insert_sql(collection, obj.iter());
881                    tx.write_set.push(PendingSql::Insert(sql));
882                    buffered += 1;
883                }
884                let tx_id = tx.tx_id;
885                return Ok(Value::Object(
886                    [
887                        ("affected".to_string(), Value::Number(0.0)),
888                        ("buffered".to_string(), Value::Number(buffered as f64)),
889                        ("pending".to_string(), Value::Bool(true)),
890                        ("tx_id".to_string(), Value::Number(tx_id as f64)),
891                    ]
892                    .into_iter()
893                    .collect(),
894                ));
895            }
896
897            if should_bulk_insert_graph(runtime, collection, &objects) {
898                return bulk_insert_graph(runtime, collection, &objects)
899                    .map_err(|e| (error_code::QUERY_ERROR, e.to_string()));
900            }
901
902            let mut total_affected: u64 = 0;
903            let mut ids = Vec::with_capacity(objects.len());
904            for chunk in objects.chunks(STDIO_BULK_INSERT_CHUNK_ROWS) {
905                let rows = chunk
906                    .iter()
907                    .map(|obj| flat_payload_to_row_input(collection, obj))
908                    .collect();
909                let outputs = runtime
910                    .create_rows_batch(CreateRowsBatchInput {
911                        collection: collection.to_string(),
912                        rows,
913                        suppress_events: false,
914                    })
915                    .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
916                total_affected += outputs.len() as u64;
917                ids.extend(
918                    outputs
919                        .into_iter()
920                        .map(|output| Value::String(output.id.raw().to_string())),
921                );
922            }
923            let mut out = json::Map::new();
924            out.insert("affected".to_string(), Value::Number(total_affected as f64));
925            out.insert("ids".to_string(), Value::Array(ids));
926            Ok(Value::Object(out))
927        }
928
929        "get" => {
930            let collection = params.get("collection").and_then(Value::as_str).ok_or((
931                error_code::INVALID_PARAMS,
932                "missing 'collection' string".to_string(),
933            ))?;
934            let id = params.get("id").and_then(Value::as_str).ok_or((
935                error_code::INVALID_PARAMS,
936                "missing 'id' string".to_string(),
937            ))?;
938            let sql = format!("SELECT * FROM {collection} WHERE rid = {id} LIMIT 1");
939            let qr = runtime
940                .execute_query(&sql)
941                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
942            let entity = qr
943                .result
944                .records
945                .first()
946                .map(record_to_json_object)
947                .unwrap_or(Value::Null);
948            Ok(Value::Object(
949                [("entity".to_string(), entity)].into_iter().collect(),
950            ))
951        }
952
953        "delete" => {
954            let collection = params.get("collection").and_then(Value::as_str).ok_or((
955                error_code::INVALID_PARAMS,
956                "missing 'collection' string".to_string(),
957            ))?;
958            let id = params.get("id").and_then(Value::as_str).ok_or((
959                error_code::INVALID_PARAMS,
960                "missing 'id' string".to_string(),
961            ))?;
962            let sql = format!("DELETE FROM {collection} WHERE rid = {id}");
963
964            if let Some(tx) = session.current_tx_mut() {
965                tx.write_set.push(PendingSql::Delete(sql));
966                return Ok(pending_tx_response(tx.tx_id));
967            }
968
969            let qr = runtime
970                .execute_query(&sql)
971                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
972            Ok(Value::Object(
973                [(
974                    "affected".to_string(),
975                    Value::Number(qr.affected_rows as f64),
976                )]
977                .into_iter()
978                .collect(),
979            ))
980        }
981
982        "close" => {
983            // Silently drop any open transaction and cursors on close.
984            // The client explicitly asked to terminate; surfacing an
985            // error here would leak state across what is effectively a
986            // reset.
987            let _ = session.take_tx();
988            session.clear_cursors();
989            let _ = runtime.checkpoint();
990            Ok(Value::Null)
991        }
992
993        // Auth surface — local stdio bridge has no auth backend
994        // (the spawned binary inherits the caller's privileges by
995        // construction). The remote bridge below maps these methods
996        // onto the gRPC server's auth endpoints.
997        "auth.login"
998        | "auth.whoami"
999        | "auth.change_password"
1000        | "auth.create_api_key"
1001        | "auth.revoke_api_key" => {
1002            let _ = (session, params);
1003            Err((
1004                error_code::INVALID_REQUEST,
1005                format!(
1006                    "{method}: auth methods are only available on grpc:// connections; \
1007                     embedded modes (memory://, file://) inherit caller privileges"
1008                ),
1009            ))
1010        }
1011
1012        other => Err((
1013            error_code::INVALID_REQUEST,
1014            format!("unknown method: {other}"),
1015        )),
1016    }
1017}
1018
1019// ---------------------------------------------------------------------------
1020// Response builders
1021// ---------------------------------------------------------------------------
1022
1023fn success_response(id: &Value, result: &Value, is_close: bool) -> String {
1024    // For `close` we tag the response so the loop knows to exit after
1025    // flushing. The tag is stripped from the wire by replacing it
1026    // before serialization — actually we just include it as a sentinel
1027    // field that drivers ignore (forward compat).
1028    let mut envelope = json::Map::new();
1029    envelope.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1030    envelope.insert("id".to_string(), id.clone());
1031    envelope.insert("result".to_string(), result.clone());
1032    if is_close {
1033        envelope.insert("__close__".to_string(), Value::Bool(true));
1034    }
1035    Value::Object(envelope).to_string_compact()
1036}
1037
1038fn error_response(id: &Value, code: &str, message: &str) -> String {
1039    let mut err = json::Map::new();
1040    err.insert("code".to_string(), Value::String(code.to_string()));
1041    err.insert("message".to_string(), Value::String(message.to_string()));
1042    err.insert("data".to_string(), Value::Null);
1043
1044    let mut envelope = json::Map::new();
1045    envelope.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1046    envelope.insert("id".to_string(), id.clone());
1047    envelope.insert("error".to_string(), Value::Object(err));
1048    Value::Object(envelope).to_string_compact()
1049}
1050
1051// ---------------------------------------------------------------------------
1052// Helpers
1053// ---------------------------------------------------------------------------
1054
1055/// Envelope returned by `insert` and `delete` when the call was buffered
1056/// into an open transaction instead of being auto-committed.
1057fn pending_tx_response(tx_id: u64) -> Value {
1058    Value::Object(
1059        [
1060            ("affected".to_string(), Value::Number(0.0)),
1061            ("pending".to_string(), Value::Bool(true)),
1062            ("tx_id".to_string(), Value::Number(tx_id as f64)),
1063        ]
1064        .into_iter()
1065        .collect(),
1066    )
1067}
1068
1069pub(crate) fn build_insert_sql<'a, I>(collection: &str, fields: I) -> String
1070where
1071    I: Iterator<Item = (&'a String, &'a Value)>,
1072{
1073    let mut cols = Vec::new();
1074    let mut vals = Vec::new();
1075    for (k, v) in fields {
1076        cols.push(k.clone());
1077        vals.push(value_to_sql_literal(v));
1078    }
1079    format!(
1080        "INSERT INTO {collection} ({}) VALUES ({})",
1081        cols.join(", "),
1082        vals.join(", "),
1083    )
1084}
1085
1086fn flat_payload_to_row_input(
1087    collection: &str,
1088    payload: &json::Map<String, Value>,
1089) -> CreateRowInput {
1090    CreateRowInput {
1091        collection: collection.to_string(),
1092        fields: payload
1093            .iter()
1094            .map(|(key, value)| (key.clone(), json_value_to_schema_value(value)))
1095            .collect(),
1096        metadata: Vec::new(),
1097        node_links: Vec::new(),
1098        vector_links: Vec::new(),
1099    }
1100}
1101
1102fn bulk_insert_chunk_count(row_count: usize) -> usize {
1103    if row_count == 0 {
1104        0
1105    } else {
1106        ((row_count - 1) / STDIO_BULK_INSERT_CHUNK_ROWS) + 1
1107    }
1108}
1109
1110pub(crate) fn should_bulk_insert_graph(
1111    runtime: &RedDBRuntime,
1112    collection: &str,
1113    payloads: &[&json::Map<String, Value>],
1114) -> bool {
1115    let graph_shaped = payloads
1116        .iter()
1117        .all(|payload| payload.get("label").and_then(Value::as_str).is_some());
1118    if !graph_shaped {
1119        return false;
1120    }
1121
1122    matches!(
1123        runtime
1124            .db()
1125            .catalog_model_snapshot()
1126            .collections
1127            .iter()
1128            .find(|descriptor| descriptor.name == collection)
1129            .map(|descriptor| descriptor.declared_model.unwrap_or(descriptor.model)),
1130        Some(crate::catalog::CollectionModel::Graph | crate::catalog::CollectionModel::Mixed)
1131    )
1132}
1133
1134pub(crate) fn bulk_insert_graph(
1135    runtime: &RedDBRuntime,
1136    collection: &str,
1137    payloads: &[&json::Map<String, Value>],
1138) -> crate::RedDBResult<Value> {
1139    use crate::application::entity_payload::{parse_create_edge_input, parse_create_node_input};
1140    use crate::application::ports::RuntimeEntityPort;
1141
1142    let mut ids = Vec::with_capacity(payloads.len());
1143    for payload in payloads {
1144        let input_payload = normalize_flat_graph_payload(payload);
1145        let id = if payload.contains_key("from") || payload.contains_key("to") {
1146            runtime
1147                .create_edge(parse_create_edge_input(
1148                    collection.to_string(),
1149                    &input_payload,
1150                )?)?
1151                .id
1152        } else {
1153            runtime
1154                .create_node(parse_create_node_input(
1155                    collection.to_string(),
1156                    &input_payload,
1157                )?)?
1158                .id
1159        };
1160        ids.push(Value::Number(id.raw() as f64));
1161    }
1162
1163    let mut out = json::Map::new();
1164    out.insert("affected".to_string(), Value::Number(ids.len() as f64));
1165    out.insert("ids".to_string(), Value::Array(ids));
1166    Ok(Value::Object(out))
1167}
1168
1169fn normalize_flat_graph_payload(payload: &json::Map<String, Value>) -> Value {
1170    if payload.contains_key("properties") || payload.contains_key("fields") {
1171        return Value::Object(payload.clone());
1172    }
1173
1174    let is_edge = payload.contains_key("from") || payload.contains_key("to");
1175    let mut normalized = payload.clone();
1176    let mut properties = json::Map::new();
1177    for (key, value) in payload {
1178        let reserved = if is_edge {
1179            matches!(
1180                key.as_str(),
1181                "label"
1182                    | "from"
1183                    | "to"
1184                    | "weight"
1185                    | "metadata"
1186                    | "properties"
1187                    | "fields"
1188                    | "_ttl_ms"
1189                    | "_expires_at"
1190            )
1191        } else {
1192            matches!(
1193                key.as_str(),
1194                "label"
1195                    | "node_type"
1196                    | "metadata"
1197                    | "links"
1198                    | "embeddings"
1199                    | "properties"
1200                    | "fields"
1201                    | "_ttl_ms"
1202                    | "_expires_at"
1203            )
1204        };
1205        if !reserved {
1206            properties.insert(key.clone(), value.clone());
1207        }
1208    }
1209    if !properties.is_empty() {
1210        normalized.insert("properties".to_string(), Value::Object(properties));
1211    }
1212    Value::Object(normalized)
1213}
1214
1215pub(crate) fn value_to_sql_literal(v: &Value) -> String {
1216    match v {
1217        Value::Null => "NULL".to_string(),
1218        Value::Bool(b) => b.to_string(),
1219        Value::Integer(n) => n.to_string(),
1220        Value::Number(n) => {
1221            if n.fract() == 0.0 {
1222                format!("{}", *n as i64)
1223            } else {
1224                n.to_string()
1225            }
1226        }
1227        Value::String(s) => format!("'{}'", s.replace('\'', "''")),
1228        other => format!("'{}'", other.to_string_compact().replace('\'', "''")),
1229    }
1230}
1231
1232pub(crate) fn query_result_to_json(qr: &RuntimeQueryResult) -> Value {
1233    if let Some(ask) = ask_query_result_to_json(qr) {
1234        return ask;
1235    }
1236
1237    let mut envelope = json::Map::new();
1238    envelope.insert(
1239        "statement".to_string(),
1240        Value::String(qr.statement_type.to_string()),
1241    );
1242    envelope.insert(
1243        "affected".to_string(),
1244        Value::Number(qr.affected_rows as f64),
1245    );
1246
1247    let mut columns = Vec::new();
1248    if let Some(first) = qr.result.records.first() {
1249        let mut keys: Vec<String> = first
1250            .column_names()
1251            .into_iter()
1252            .map(|k| k.to_string())
1253            .collect();
1254        keys.sort();
1255        columns = keys.into_iter().map(Value::String).collect();
1256    }
1257    envelope.insert("columns".to_string(), Value::Array(columns));
1258
1259    let rows: Vec<Value> = qr
1260        .result
1261        .records
1262        .iter()
1263        .map(record_to_json_object)
1264        .collect();
1265    envelope.insert("rows".to_string(), Value::Array(rows));
1266
1267    Value::Object(envelope)
1268}
1269
1270fn ask_query_result_to_json(qr: &RuntimeQueryResult) -> Option<Value> {
1271    if qr.statement != "ask" {
1272        return None;
1273    }
1274    let row = qr.result.records.first()?;
1275    let answer = text_field(row, "answer")?;
1276    let provider = text_field(row, "provider").unwrap_or_default();
1277    let model = text_field(row, "model").unwrap_or_default();
1278    let sources_flat_json = json_field(row, "sources_flat").unwrap_or(Value::Array(Vec::new()));
1279    let citations_json = json_field(row, "citations").unwrap_or(Value::Array(Vec::new()));
1280    let validation_json = json_field(row, "validation").unwrap_or(Value::Object(json::Map::new()));
1281
1282    let effective_mode = match text_field(row, "mode").as_deref() {
1283        Some("lenient") => crate::runtime::ai::ask_response_envelope::Mode::Lenient,
1284        _ => crate::runtime::ai::ask_response_envelope::Mode::Strict,
1285    };
1286
1287    let result = crate::runtime::ai::ask_response_envelope::AskResult {
1288        answer,
1289        sources_flat: envelope_sources_flat(&sources_flat_json),
1290        citations: envelope_citations(&citations_json),
1291        validation: envelope_validation(&validation_json),
1292        cache_hit: bool_field(row, "cache_hit").unwrap_or(false),
1293        provider,
1294        model,
1295        prompt_tokens: u32_field(row, "prompt_tokens").unwrap_or(0),
1296        completion_tokens: u32_field(row, "completion_tokens").unwrap_or(0),
1297        cost_usd: f64_field(row, "cost_usd").unwrap_or(0.0),
1298        effective_mode,
1299        retry_count: u32_field(row, "retry_count").unwrap_or(0),
1300    };
1301    Some(crate::runtime::ai::ask_response_envelope::build(&result))
1302}
1303
1304fn record_field<'a>(record: &'a UnifiedRecord, name: &str) -> Option<&'a SchemaValue> {
1305    record
1306        .iter_fields()
1307        .find_map(|(key, value)| (key.as_ref() == name).then_some(value))
1308}
1309
1310fn text_field(record: &UnifiedRecord, name: &str) -> Option<String> {
1311    match record_field(record, name)? {
1312        SchemaValue::Text(s) => Some(s.to_string()),
1313        SchemaValue::Email(s)
1314        | SchemaValue::Url(s)
1315        | SchemaValue::NodeRef(s)
1316        | SchemaValue::EdgeRef(s) => Some(s.clone()),
1317        other => Some(format!("{other}")),
1318    }
1319}
1320
1321fn u32_field(record: &UnifiedRecord, name: &str) -> Option<u32> {
1322    match record_field(record, name)? {
1323        SchemaValue::Integer(n) => (*n >= 0).then_some((*n).min(u32::MAX as i64) as u32),
1324        SchemaValue::UnsignedInteger(n) => Some((*n).min(u32::MAX as u64) as u32),
1325        SchemaValue::BigInt(n)
1326        | SchemaValue::TimestampMs(n)
1327        | SchemaValue::Timestamp(n)
1328        | SchemaValue::Duration(n)
1329        | SchemaValue::Decimal(n) => (*n >= 0).then_some((*n).min(u32::MAX as i64) as u32),
1330        SchemaValue::Float(n) => (*n >= 0.0).then_some((*n).min(u32::MAX as f64) as u32),
1331        _ => None,
1332    }
1333}
1334
1335fn f64_field(record: &UnifiedRecord, name: &str) -> Option<f64> {
1336    match record_field(record, name)? {
1337        SchemaValue::Integer(n) => Some(*n as f64),
1338        SchemaValue::UnsignedInteger(n) => Some(*n as f64),
1339        SchemaValue::BigInt(n)
1340        | SchemaValue::TimestampMs(n)
1341        | SchemaValue::Timestamp(n)
1342        | SchemaValue::Duration(n)
1343        | SchemaValue::Decimal(n) => Some(*n as f64),
1344        SchemaValue::Float(n) => Some(*n),
1345        _ => None,
1346    }
1347}
1348
1349fn bool_field(record: &UnifiedRecord, name: &str) -> Option<bool> {
1350    match record_field(record, name)? {
1351        SchemaValue::Boolean(value) => Some(*value),
1352        _ => None,
1353    }
1354}
1355
1356fn json_field(record: &UnifiedRecord, name: &str) -> Option<Value> {
1357    match record_field(record, name)? {
1358        SchemaValue::Json(bytes) => {
1359            // Decode the native binary document-body container (PRD-1398) if present.
1360            crate::document_body::decode_container_to_json(bytes)
1361                .or_else(|| json::from_slice(bytes).ok())
1362        }
1363        SchemaValue::Text(text) => json::from_str(text).ok(),
1364        _ => None,
1365    }
1366}
1367
1368fn envelope_sources_flat(
1369    value: &Value,
1370) -> Vec<crate::runtime::ai::ask_response_envelope::SourceRow> {
1371    value
1372        .as_array()
1373        .unwrap_or(&[])
1374        .iter()
1375        .filter_map(|source| {
1376            let urn = source.get("urn").and_then(Value::as_str)?.to_string();
1377            let payload = source
1378                .get("payload")
1379                .and_then(Value::as_str)
1380                .map(ToString::to_string)
1381                .unwrap_or_else(|| source.to_string_compact());
1382            Some(crate::runtime::ai::ask_response_envelope::SourceRow { urn, payload })
1383        })
1384        .collect()
1385}
1386
1387fn envelope_citations(value: &Value) -> Vec<crate::runtime::ai::ask_response_envelope::Citation> {
1388    value
1389        .as_array()
1390        .unwrap_or(&[])
1391        .iter()
1392        .filter_map(|citation| {
1393            let marker = citation.get("marker").and_then(Value::as_u64)?;
1394            let urn = citation.get("urn").and_then(Value::as_str)?.to_string();
1395            Some(crate::runtime::ai::ask_response_envelope::Citation {
1396                marker: marker.min(u32::MAX as u64) as u32,
1397                urn,
1398            })
1399        })
1400        .collect()
1401}
1402
1403fn envelope_validation(value: &Value) -> crate::runtime::ai::ask_response_envelope::Validation {
1404    crate::runtime::ai::ask_response_envelope::Validation {
1405        ok: value.get("ok").and_then(Value::as_bool).unwrap_or(true),
1406        warnings: validation_items(value, "warnings")
1407            .into_iter()
1408            .map(
1409                |(kind, detail)| crate::runtime::ai::ask_response_envelope::ValidationWarning {
1410                    kind,
1411                    detail,
1412                },
1413            )
1414            .collect(),
1415        errors: validation_items(value, "errors")
1416            .into_iter()
1417            .map(
1418                |(kind, detail)| crate::runtime::ai::ask_response_envelope::ValidationError {
1419                    kind,
1420                    detail,
1421                },
1422            )
1423            .collect(),
1424    }
1425}
1426
1427fn validation_items(value: &Value, key: &str) -> Vec<(String, String)> {
1428    value
1429        .get(key)
1430        .and_then(Value::as_array)
1431        .unwrap_or(&[])
1432        .iter()
1433        .filter_map(|item| {
1434            Some((
1435                item.get("kind").and_then(Value::as_str)?.to_string(),
1436                item.get("detail")
1437                    .and_then(Value::as_str)
1438                    .unwrap_or("")
1439                    .to_string(),
1440            ))
1441        })
1442        .collect()
1443}
1444
1445pub(crate) fn insert_result_to_json(qr: &RuntimeQueryResult) -> Value {
1446    let mut envelope = json::Map::new();
1447    envelope.insert(
1448        "affected".to_string(),
1449        Value::Number(qr.affected_rows as f64),
1450    );
1451    // First row of the result, if any, contains the inserted entity id.
1452    if let Some(first) = qr.result.records.first() {
1453        if let Some(id_val) = first
1454            .iter_fields()
1455            .find(|(k, _)| {
1456                let s: &str = k;
1457                s == "_entity_id"
1458            })
1459            .map(|(_, v)| schema_value_to_json(v))
1460        {
1461            envelope.insert("id".to_string(), id_val);
1462        }
1463    }
1464    Value::Object(envelope)
1465}
1466
1467fn record_to_json_object(record: &UnifiedRecord) -> Value {
1468    let mut map = json::Map::new();
1469    // iter_fields merges the columnar fast-path + HashMap so scan
1470    // rows (columnar only) contribute their values.
1471    let mut entries: Vec<(&str, &SchemaValue)> =
1472        record.iter_fields().map(|(k, v)| (k.as_ref(), v)).collect();
1473    entries.sort_by(|a, b| a.0.cmp(b.0));
1474    for (k, v) in entries {
1475        map.insert(k.to_string(), schema_value_to_json(v));
1476    }
1477    Value::Object(map)
1478}
1479
1480fn schema_value_to_json(v: &SchemaValue) -> Value {
1481    match v {
1482        SchemaValue::Null => Value::Null,
1483        SchemaValue::Boolean(b) => Value::Bool(*b),
1484        SchemaValue::Integer(n) => exact_i64_to_json(*n),
1485        SchemaValue::UnsignedInteger(n) => exact_u64_to_json(*n),
1486        SchemaValue::Float(n) if n.is_finite() => Value::Number(*n),
1487        SchemaValue::Float(n) => {
1488            let token = if n.is_nan() {
1489                "NaN"
1490            } else if n.is_sign_positive() {
1491                "Infinity"
1492            } else {
1493                "-Infinity"
1494            };
1495            single_key_object("$float", Value::String(token.to_string()))
1496        }
1497        SchemaValue::BigInt(n) => exact_i64_to_json(*n),
1498        SchemaValue::TimestampMs(n) | SchemaValue::Duration(n) => exact_i64_to_json(*n),
1499        SchemaValue::Decimal(n) => exact_decimal_to_json(SchemaValue::Decimal(*n).display_string()),
1500        SchemaValue::DecimalText(n) => exact_decimal_to_json(n.clone()),
1501        SchemaValue::Timestamp(n) => single_key_object("$ts", Value::String(n.to_string())),
1502        SchemaValue::Password(_) | SchemaValue::Secret(_) => Value::String("***".to_string()),
1503        SchemaValue::Text(s) => Value::String(s.to_string()),
1504        SchemaValue::Blob(bytes) => {
1505            single_key_object("$bytes", Value::String(base64_encode(bytes)))
1506        }
1507        SchemaValue::Json(bytes) => {
1508            crate::presentation::entity_json::storage_json_bytes_to_json(bytes)
1509        }
1510        SchemaValue::Uuid(bytes) => single_key_object("$uuid", Value::String(format_uuid(bytes))),
1511        SchemaValue::Email(s)
1512        | SchemaValue::Url(s)
1513        | SchemaValue::NodeRef(s)
1514        | SchemaValue::EdgeRef(s) => Value::String(s.clone()),
1515        other => Value::String(format!("{other}")),
1516    }
1517}
1518
1519fn single_key_object(key: &str, value: Value) -> Value {
1520    Value::Object([(key.to_string(), value)].into_iter().collect())
1521}
1522
1523const MAX_JSON_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
1524
1525fn exact_i64_to_json(value: i64) -> Value {
1526    if (-MAX_JSON_SAFE_INTEGER..=MAX_JSON_SAFE_INTEGER).contains(&value) {
1527        Value::Integer(value)
1528    } else {
1529        single_key_object("$int", Value::String(value.to_string()))
1530    }
1531}
1532
1533fn exact_u64_to_json(value: u64) -> Value {
1534    if value <= MAX_JSON_SAFE_INTEGER as u64 {
1535        Value::Integer(value as i64)
1536    } else {
1537        single_key_object("$uint", Value::String(value.to_string()))
1538    }
1539}
1540
1541fn exact_decimal_to_json(value: String) -> Value {
1542    single_key_object("$decimal", Value::String(value))
1543}
1544
1545/// Convert a JSON `Value` to a `SchemaValue` for use as a bind parameter
1546/// in a prepared statement. JSON-RPC envelopes preserve values that
1547/// ordinary JSON cannot represent losslessly.
1548pub(crate) fn json_value_to_schema_value(v: &Value) -> SchemaValue {
1549    try_json_value_to_schema_value(v)
1550        .unwrap_or_else(|_| SchemaValue::Json(crate::json::to_vec(v).unwrap_or_default()))
1551}
1552
1553fn try_json_value_to_schema_value(v: &Value) -> Result<SchemaValue, String> {
1554    match v {
1555        Value::Null => Ok(SchemaValue::Null),
1556        Value::Bool(b) => Ok(SchemaValue::Boolean(*b)),
1557        Value::Integer(n) => Ok(SchemaValue::Integer(*n)),
1558        Value::Number(n) => {
1559            if n.is_finite() && n.fract() == 0.0 && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
1560                Ok(SchemaValue::Integer(*n as i64))
1561            } else {
1562                Ok(SchemaValue::Float(*n))
1563            }
1564        }
1565        Value::Decimal(n) => Ok(SchemaValue::DecimalText(n.clone())),
1566        Value::String(s) => Ok(SchemaValue::text(s.clone())),
1567        Value::Array(items) => {
1568            // A JSON array of numbers (or empty) is taken as `Vector`
1569            // for the #355 query-param contract. Other arrays are
1570            // JSON values, so JSON columns can bind array payloads.
1571            if items
1572                .iter()
1573                .all(|v| matches!(v, Value::Integer(_) | Value::Number(_)))
1574            {
1575                let floats: Vec<f32> = items
1576                    .iter()
1577                    .map(|v| v.as_f64().unwrap_or(0.0) as f32)
1578                    .collect();
1579                Ok(SchemaValue::Vector(floats))
1580            } else {
1581                Ok(SchemaValue::Json(
1582                    crate::json::to_vec(v).unwrap_or_default(),
1583                ))
1584            }
1585        }
1586        Value::Object(map) => {
1587            if map.len() == 1 {
1588                if let Some(Value::String(encoded)) = map.get("$bytes") {
1589                    if let Ok(bytes) = base64_decode(encoded) {
1590                        return Ok(SchemaValue::Blob(bytes));
1591                    }
1592                }
1593                if let Some(value) = map.get("$ts") {
1594                    if let Some(ts) = json_i64(value) {
1595                        return Ok(SchemaValue::Timestamp(ts));
1596                    }
1597                }
1598                if let Some(Value::String(value)) = map.get("$uuid") {
1599                    if let Ok(uuid) = crate::crypto::Uuid::parse_str(value) {
1600                        return Ok(SchemaValue::Uuid(*uuid.as_bytes()));
1601                    }
1602                }
1603                if let Some(Value::String(value)) = map.get("$float") {
1604                    return Ok(match value.as_str() {
1605                        "NaN" => SchemaValue::Float(f64::NAN),
1606                        "Infinity" | "+Infinity" | "inf" | "+inf" => {
1607                            SchemaValue::Float(f64::INFINITY)
1608                        }
1609                        "-Infinity" | "-inf" => SchemaValue::Float(f64::NEG_INFINITY),
1610                        _ => SchemaValue::Json(crate::json::to_vec(v).unwrap_or_default()),
1611                    });
1612                }
1613                if map.contains_key("$number") || map.contains_key("$decimalText") {
1614                    return Err("superseded exact-number envelope".to_string());
1615                }
1616                if let Some(value) = map.get("$int") {
1617                    return json_i64(value)
1618                        .map(SchemaValue::Integer)
1619                        .ok_or_else(|| "invalid $int exact-number envelope".to_string());
1620                }
1621                if let Some(value) = map.get("$uint") {
1622                    if let Value::String(value) = value {
1623                        return value
1624                            .parse::<u64>()
1625                            .map(SchemaValue::UnsignedInteger)
1626                            .map_err(|_| "invalid $uint exact-number envelope".to_string());
1627                    }
1628                    return Err("invalid $uint exact-number envelope".to_string());
1629                }
1630                if let Some(Value::String(value)) = map.get("$decimal") {
1631                    return Ok(SchemaValue::DecimalText(value.clone()));
1632                }
1633            }
1634            Ok(SchemaValue::Json(
1635                crate::json::to_vec(v).unwrap_or_default(),
1636            ))
1637        }
1638    }
1639}
1640
1641fn json_i64(value: &Value) -> Option<i64> {
1642    match value {
1643        Value::Integer(n) => Some(*n),
1644        Value::Number(n) => {
1645            if n.is_finite() && n.fract() == 0.0 && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
1646                Some(*n as i64)
1647            } else {
1648                None
1649            }
1650        }
1651        Value::String(s) => s.parse::<i64>().ok(),
1652        _ => None,
1653    }
1654}
1655
1656fn format_uuid(bytes: &[u8; 16]) -> String {
1657    format!(
1658        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1659        bytes[0],
1660        bytes[1],
1661        bytes[2],
1662        bytes[3],
1663        bytes[4],
1664        bytes[5],
1665        bytes[6],
1666        bytes[7],
1667        bytes[8],
1668        bytes[9],
1669        bytes[10],
1670        bytes[11],
1671        bytes[12],
1672        bytes[13],
1673        bytes[14],
1674        bytes[15]
1675    )
1676}
1677
1678fn base64_encode(bytes: &[u8]) -> String {
1679    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1680    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
1681    let mut chunks = bytes.chunks_exact(3);
1682    for chunk in chunks.by_ref() {
1683        let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
1684        out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
1685        out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
1686        out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
1687        out.push(TABLE[(n & 0x3f) as usize] as char);
1688    }
1689    match chunks.remainder() {
1690        [] => {}
1691        [a] => {
1692            let n = (*a as u32) << 16;
1693            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
1694            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
1695            out.push('=');
1696            out.push('=');
1697        }
1698        [a, b] => {
1699            let n = ((*a as u32) << 16) | ((*b as u32) << 8);
1700            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
1701            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
1702            out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
1703            out.push('=');
1704        }
1705        _ => unreachable!(),
1706    }
1707    out
1708}
1709
1710fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
1711    let bytes = input.as_bytes();
1712    if !bytes.len().is_multiple_of(4) {
1713        return Err("base64 length must be a multiple of 4".to_string());
1714    }
1715    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1716    for chunk in bytes.chunks_exact(4) {
1717        let pad = chunk.iter().rev().take_while(|&&b| b == b'=').count();
1718        let a = base64_value(chunk[0])?;
1719        let b = base64_value(chunk[1])?;
1720        let c = if chunk[2] == b'=' {
1721            0
1722        } else {
1723            base64_value(chunk[2])?
1724        };
1725        let d = if chunk[3] == b'=' {
1726            0
1727        } else {
1728            base64_value(chunk[3])?
1729        };
1730        let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | d as u32;
1731        out.push(((n >> 16) & 0xff) as u8);
1732        if pad < 2 {
1733            out.push(((n >> 8) & 0xff) as u8);
1734        }
1735        if pad < 1 {
1736            out.push((n & 0xff) as u8);
1737        }
1738    }
1739    Ok(out)
1740}
1741
1742fn base64_value(byte: u8) -> Result<u8, String> {
1743    match byte {
1744        b'A'..=b'Z' => Ok(byte - b'A'),
1745        b'a'..=b'z' => Ok(byte - b'a' + 26),
1746        b'0'..=b'9' => Ok(byte - b'0' + 52),
1747        b'+' => Ok(62),
1748        b'/' => Ok(63),
1749        b'=' => Ok(0),
1750        _ => Err(format!("invalid base64 character: {}", byte as char)),
1751    }
1752}
1753
1754// ---------------------------------------------------------------------------
1755// Remote dispatch (grpc://)
1756// ---------------------------------------------------------------------------
1757
1758/// Dispatch a parsed JSON-RPC call over gRPC. Mirrors `dispatch_method`
1759/// but every operation goes through the tonic client. The server's
1760/// own `RedDBRuntime` does the actual work — we are just a wire
1761/// adapter between the JSON-RPC framing the drivers speak and the
1762/// gRPC protobuf framing the server speaks.
1763fn dispatch_method_remote(
1764    client: &AsyncMutex<RedDBClient>,
1765    tokio_rt: &tokio::runtime::Runtime,
1766    method: &str,
1767    params: &Value,
1768) -> Result<Value, (&'static str, String)> {
1769    match method {
1770        "version" => Ok(Value::Object(
1771            [
1772                (
1773                    "version".to_string(),
1774                    Value::String(env!("CARGO_PKG_VERSION").to_string()),
1775                ),
1776                (
1777                    "protocol".to_string(),
1778                    Value::String(PROTOCOL_VERSION.to_string()),
1779                ),
1780            ]
1781            .into_iter()
1782            .collect(),
1783        )),
1784
1785        "health" => {
1786            let result = tokio_rt.block_on(async {
1787                let mut guard = client.lock().await;
1788                guard.health_status().await
1789            });
1790            match result {
1791                Ok(status) => Ok(Value::Object(
1792                    [
1793                        ("ok".to_string(), Value::Bool(status.healthy)),
1794                        ("state".to_string(), Value::String(status.state)),
1795                        (
1796                            "checked_at_unix_ms".to_string(),
1797                            Value::Number(status.checked_at_unix_ms as f64),
1798                        ),
1799                        (
1800                            "version".to_string(),
1801                            Value::String(env!("CARGO_PKG_VERSION").to_string()),
1802                        ),
1803                    ]
1804                    .into_iter()
1805                    .collect(),
1806                )),
1807                Err(e) => Err((error_code::INTERNAL_ERROR, e.to_string())),
1808            }
1809        }
1810
1811        "query" => {
1812            let sql = params.get("sql").and_then(Value::as_str).ok_or((
1813                error_code::INVALID_PARAMS,
1814                "missing 'sql' string".to_string(),
1815            ))?;
1816            let json_str = tokio_rt
1817                .block_on(async {
1818                    let mut guard = client.lock().await;
1819                    guard.query(sql).await
1820                })
1821                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1822            // Server returned its own QueryReply.result_json. Parse and
1823            // repackage into the stdio-protocol shape. If parsing fails,
1824            // hand the raw server JSON back under a sentinel key so the
1825            // caller still gets something useful.
1826            let parsed = json::from_str::<Value>(&json_str)
1827                .map_err(|e| (error_code::INTERNAL_ERROR, format!("bad server JSON: {e}")))?;
1828            Ok(parsed)
1829        }
1830
1831        "insert" => {
1832            let collection = params.get("collection").and_then(Value::as_str).ok_or((
1833                error_code::INVALID_PARAMS,
1834                "missing 'collection' string".to_string(),
1835            ))?;
1836            let payload = params.get("payload").ok_or((
1837                error_code::INVALID_PARAMS,
1838                "missing 'payload' object".to_string(),
1839            ))?;
1840            if payload.as_object().is_none() {
1841                return Err((
1842                    error_code::INVALID_PARAMS,
1843                    "'payload' must be a JSON object".to_string(),
1844                ));
1845            }
1846            let payload_json = payload.to_string_compact();
1847            let reply = tokio_rt
1848                .block_on(async {
1849                    let mut guard = client.lock().await;
1850                    guard.create_row_entity(collection, &payload_json).await
1851                })
1852                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1853            let mut out = json::Map::new();
1854            out.insert("affected".to_string(), Value::Number(1.0));
1855            out.insert("id".to_string(), Value::String(reply.id.to_string()));
1856            Ok(Value::Object(out))
1857        }
1858
1859        "bulk_insert" => {
1860            let collection = params.get("collection").and_then(Value::as_str).ok_or((
1861                error_code::INVALID_PARAMS,
1862                "missing 'collection' string".to_string(),
1863            ))?;
1864            let payloads = params.get("payloads").and_then(Value::as_array).ok_or((
1865                error_code::INVALID_PARAMS,
1866                "missing 'payloads' array".to_string(),
1867            ))?;
1868            let mut encoded = Vec::with_capacity(payloads.len());
1869            for entry in payloads {
1870                if entry.as_object().is_none() {
1871                    return Err((
1872                        error_code::INVALID_PARAMS,
1873                        "each payload must be a JSON object".to_string(),
1874                    ));
1875                }
1876                encoded.push(entry.to_string_compact());
1877            }
1878            let status = tokio_rt
1879                .block_on(async {
1880                    let mut guard = client.lock().await;
1881                    guard.bulk_create_rows(collection, encoded).await
1882                })
1883                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1884            Ok(Value::Object(
1885                [
1886                    ("affected".to_string(), Value::Number(status.count as f64)),
1887                    (
1888                        "ids".to_string(),
1889                        Value::Array(
1890                            status
1891                                .ids
1892                                .into_iter()
1893                                .map(|id| Value::Number(id as f64))
1894                                .collect(),
1895                        ),
1896                    ),
1897                ]
1898                .into_iter()
1899                .collect(),
1900            ))
1901        }
1902
1903        "get" => {
1904            let collection = params.get("collection").and_then(Value::as_str).ok_or((
1905                error_code::INVALID_PARAMS,
1906                "missing 'collection' string".to_string(),
1907            ))?;
1908            let id = params.get("id").and_then(Value::as_str).ok_or((
1909                error_code::INVALID_PARAMS,
1910                "missing 'id' string".to_string(),
1911            ))?;
1912            let sql = format!("SELECT * FROM {collection} WHERE rid = {id} LIMIT 1");
1913            let json_str = tokio_rt
1914                .block_on(async {
1915                    let mut guard = client.lock().await;
1916                    guard.query(&sql).await
1917                })
1918                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1919            let parsed = json::from_str::<Value>(&json_str)
1920                .map_err(|e| (error_code::INTERNAL_ERROR, format!("bad server JSON: {e}")))?;
1921            // Server response shape: {"rows":[{...}], ...}. Extract
1922            // the first row (if any) as `entity`.
1923            let entity = parsed
1924                .get("rows")
1925                .and_then(Value::as_array)
1926                .and_then(|rows| rows.first().cloned())
1927                .unwrap_or(Value::Null);
1928            Ok(Value::Object(
1929                [("entity".to_string(), entity)].into_iter().collect(),
1930            ))
1931        }
1932
1933        "delete" => {
1934            let collection = params.get("collection").and_then(Value::as_str).ok_or((
1935                error_code::INVALID_PARAMS,
1936                "missing 'collection' string".to_string(),
1937            ))?;
1938            let id = params.get("id").and_then(Value::as_str).ok_or((
1939                error_code::INVALID_PARAMS,
1940                "missing 'id' string".to_string(),
1941            ))?;
1942            let id = id.parse::<u64>().map_err(|_| {
1943                (
1944                    error_code::INVALID_PARAMS,
1945                    "id must be a numeric string".to_string(),
1946                )
1947            })?;
1948            let _reply = tokio_rt
1949                .block_on(async {
1950                    let mut guard = client.lock().await;
1951                    guard.delete_entity(collection, id).await
1952                })
1953                .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1954            Ok(Value::Object(
1955                [("affected".to_string(), Value::Number(1.0))]
1956                    .into_iter()
1957                    .collect(),
1958            ))
1959        }
1960
1961        "close" => Ok(Value::Null),
1962
1963        other => Err((
1964            error_code::INVALID_REQUEST,
1965            format!("unknown method: {other}"),
1966        )),
1967    }
1968}
1969
1970#[cfg(test)]
1971mod tests {
1972    use super::*;
1973    use crate::json::json;
1974    use proptest::prelude::*;
1975
1976    fn make_runtime() -> RedDBRuntime {
1977        RedDBRuntime::in_memory().expect("in-memory runtime")
1978    }
1979
1980    fn create_graph_collection(rt: &RedDBRuntime, name: &str) {
1981        let db = rt.db();
1982        db.store()
1983            .create_collection(name)
1984            .expect("create collection");
1985        let now = std::time::SystemTime::now()
1986            .duration_since(std::time::UNIX_EPOCH)
1987            .unwrap_or_default()
1988            .as_millis();
1989        db.save_collection_contract(crate::physical::CollectionContract {
1990            name: name.to_string(),
1991            declared_model: crate::catalog::CollectionModel::Graph,
1992            schema_mode: crate::catalog::SchemaMode::Dynamic,
1993            origin: crate::physical::ContractOrigin::Explicit,
1994            version: 1,
1995            created_at_unix_ms: now,
1996            updated_at_unix_ms: now,
1997            default_ttl_ms: None,
1998            vector_dimension: None,
1999            vector_metric: None,
2000            context_index_fields: Vec::new(),
2001            declared_columns: Vec::new(),
2002            table_def: None,
2003            timestamps_enabled: false,
2004            context_index_enabled: false,
2005            metrics_raw_retention_ms: None,
2006            metrics_rollup_policies: Vec::new(),
2007            metrics_tenant_identity: None,
2008            metrics_namespace: None,
2009            append_only: false,
2010            subscriptions: Vec::new(),
2011            analytics_config: Vec::new(),
2012            session_key: None,
2013            session_gap_ms: None,
2014            retention_duration_ms: None,
2015            analytical_storage: None,
2016
2017            ai_policy: None,
2018        })
2019        .expect("save graph contract");
2020    }
2021
2022    fn handle(rt: &RedDBRuntime, line: &str) -> String {
2023        let mut session = Session::new();
2024        handle_line(&Backend::Local(rt), &mut session, line)
2025    }
2026
2027    fn query_request(id: u64, sql: &str) -> String {
2028        let mut params = json::Map::new();
2029        params.insert("sql".to_string(), Value::String(sql.to_string()));
2030
2031        let mut request = json::Map::new();
2032        request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
2033        request.insert("id".to_string(), Value::Number(id as f64));
2034        request.insert("method".to_string(), Value::String("query".to_string()));
2035        request.insert("params".to_string(), Value::Object(params));
2036        Value::Object(request).to_string_compact()
2037    }
2038
2039    fn query_request_with_params(id: u64, sql: &str, binds: Vec<Value>) -> String {
2040        let mut params = json::Map::new();
2041        params.insert("sql".to_string(), Value::String(sql.to_string()));
2042        params.insert("params".to_string(), Value::Array(binds));
2043
2044        let mut request = json::Map::new();
2045        request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
2046        request.insert("id".to_string(), Value::Number(id as f64));
2047        request.insert("method".to_string(), Value::String("query".to_string()));
2048        request.insert("params".to_string(), Value::Object(params));
2049        Value::Object(request).to_string_compact()
2050    }
2051
2052    /// Stateful helper: keeps the same `Session` across multiple calls so
2053    /// tests can exercise multi-step transaction flows in a single closure.
2054    fn with_session<F>(rt: &RedDBRuntime, f: F)
2055    where
2056        F: FnOnce(&dyn Fn(&str) -> String, &RedDBRuntime),
2057    {
2058        let session = std::cell::RefCell::new(Session::new());
2059        let call = |line: &str| -> String {
2060            let mut s = session.borrow_mut();
2061            handle_line(&Backend::Local(rt), &mut s, line)
2062        };
2063        f(&call, rt);
2064    }
2065
2066    fn result_rows(response: &str) -> Vec<Value> {
2067        json::from_str::<Value>(response)
2068            .expect("json response")
2069            .get("result")
2070            .and_then(|result| result.get("rows"))
2071            .and_then(Value::as_array)
2072            .map(|rows| rows.to_vec())
2073            .unwrap_or_default()
2074    }
2075
2076    fn result_name_kind(response: &str) -> Vec<(String, String)> {
2077        result_rows(response)
2078            .into_iter()
2079            .map(|row| {
2080                let object = row.as_object().expect("row object");
2081                let name = object
2082                    .get("name")
2083                    .and_then(Value::as_str)
2084                    .expect("row name")
2085                    .to_string();
2086                let kind = object
2087                    .get("kind")
2088                    .and_then(Value::as_str)
2089                    .expect("row kind")
2090                    .to_string();
2091                (name, kind)
2092            })
2093            .collect()
2094    }
2095
2096    fn json_scalar_param() -> impl Strategy<Value = Value> {
2097        prop_oneof![
2098            Just(Value::Null),
2099            any::<bool>().prop_map(Value::Bool),
2100            (-1000_i64..1000_i64).prop_map(|n| Value::Number(n as f64)),
2101            "[a-z']{0,8}".prop_map(Value::String),
2102        ]
2103    }
2104
2105    fn sql_literal_for_json(value: &Value) -> String {
2106        match value {
2107            Value::Null => "NULL".to_string(),
2108            Value::Bool(true) => "TRUE".to_string(),
2109            Value::Bool(false) => "FALSE".to_string(),
2110            Value::Number(n) => format!("{n:.0}"),
2111            Value::String(s) => format!("'{}'", s.replace('\'', "''")),
2112            _ => panic!("unsupported scalar param: {value:?}"),
2113        }
2114    }
2115
2116    #[test]
2117    fn version_method_returns_version_and_protocol() {
2118        let rt = make_runtime();
2119        let line = r#"{"jsonrpc":"2.0","id":1,"method":"version","params":{}}"#;
2120        let resp = handle(&rt, line);
2121        assert!(resp.contains("\"id\":1"));
2122        assert!(resp.contains("\"protocol\":\"1.0\""));
2123        assert!(resp.contains("\"version\""));
2124    }
2125
2126    #[test]
2127    fn health_method_returns_ok_true() {
2128        let rt = make_runtime();
2129        let resp = handle(
2130            &rt,
2131            r#"{"jsonrpc":"2.0","id":"abc","method":"health","params":{}}"#,
2132        );
2133        assert!(resp.contains("\"ok\":true"));
2134        assert!(resp.contains("\"id\":\"abc\""));
2135    }
2136
2137    #[test]
2138    fn parse_error_for_invalid_json() {
2139        let rt = make_runtime();
2140        let resp = handle(&rt, "not json {");
2141        assert!(resp.contains("\"code\":\"PARSE_ERROR\""));
2142        assert!(resp.contains("\"id\":null"));
2143    }
2144
2145    #[test]
2146    fn invalid_request_when_method_missing() {
2147        let rt = make_runtime();
2148        let resp = handle(&rt, r#"{"jsonrpc":"2.0","id":1,"params":{}}"#);
2149        assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
2150    }
2151
2152    #[test]
2153    fn unknown_method_is_invalid_request() {
2154        let rt = make_runtime();
2155        let resp = handle(
2156            &rt,
2157            r#"{"jsonrpc":"2.0","id":1,"method":"frobnicate","params":{}}"#,
2158        );
2159        assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
2160        assert!(resp.contains("frobnicate"));
2161    }
2162
2163    #[test]
2164    fn invalid_params_when_query_sql_missing() {
2165        let rt = make_runtime();
2166        let resp = handle(
2167            &rt,
2168            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{}}"#,
2169        );
2170        assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
2171    }
2172
2173    #[test]
2174    fn query_params_reject_superseded_exact_number_envelope() {
2175        let rt = make_runtime();
2176        let resp = handle(
2177            &rt,
2178            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT $1","params":[{"$number":"1"}]}}"#,
2179        );
2180        assert!(resp.contains("\"code\":\"INVALID_PARAMS\""), "got: {resp}");
2181        assert!(
2182            resp.contains("superseded exact-number envelope"),
2183            "got: {resp}"
2184        );
2185    }
2186
2187    #[test]
2188    fn close_method_marks_response_for_shutdown() {
2189        let rt = make_runtime();
2190        let resp = handle(
2191            &rt,
2192            r#"{"jsonrpc":"2.0","id":1,"method":"close","params":{}}"#,
2193        );
2194        assert!(resp.contains("\"__close__\":true"));
2195    }
2196
2197    #[test]
2198    fn query_with_int_text_params_round_trips() {
2199        let rt = make_runtime();
2200        let _ = handle(
2201            &rt,
2202            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE p (id INTEGER, name TEXT)"}}"#,
2203        );
2204        let _ = handle(
2205            &rt,
2206            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (1, 'Alice')"}}"#,
2207        );
2208        let _ = handle(
2209            &rt,
2210            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (2, 'Bob')"}}"#,
2211        );
2212        let resp = handle(
2213            &rt,
2214            r#"{"jsonrpc":"2.0","id":4,"method":"query","params":{"sql":"SELECT * FROM p WHERE id = $1 AND name = $2","params":[1,"Alice"]}}"#,
2215        );
2216        assert!(resp.contains("\"Alice\""), "got: {resp}");
2217        assert!(!resp.contains("\"Bob\""), "got: {resp}");
2218    }
2219
2220    #[test]
2221    fn query_with_question_params_covers_select_insert_update_delete() {
2222        let rt = make_runtime();
2223        let create = handle(
2224            &rt,
2225            &query_request(1, "CREATE TABLE qp (id INTEGER, name TEXT)"),
2226        );
2227        assert!(!create.contains("\"error\""), "got: {create}");
2228
2229        let inserted = handle(
2230            &rt,
2231            &query_request_with_params(
2232                2,
2233                "INSERT INTO qp (id, name) VALUES (?, ?)",
2234                vec![json!(1), json!("O'Reilly")],
2235            ),
2236        );
2237        assert!(inserted.contains("\"affected\":1"), "got: {inserted}");
2238
2239        let selected = handle(
2240            &rt,
2241            &query_request_with_params(3, "SELECT name FROM qp WHERE id = ?", vec![json!(1)]),
2242        );
2243        let rows = result_rows(&selected);
2244        assert_eq!(rows.len(), 1, "got: {selected}");
2245        assert_eq!(
2246            rows[0].get("name").and_then(Value::as_str),
2247            Some("O'Reilly")
2248        );
2249
2250        let selected_numbered = handle(
2251            &rt,
2252            &query_request_with_params(
2253                4,
2254                "SELECT name FROM qp WHERE name = ?1 AND id = ?2",
2255                vec![json!("O'Reilly"), json!(1)],
2256            ),
2257        );
2258        assert_eq!(
2259            result_rows(&selected_numbered).len(),
2260            1,
2261            "got: {selected_numbered}"
2262        );
2263
2264        let updated = handle(
2265            &rt,
2266            &query_request_with_params(
2267                5,
2268                "UPDATE qp SET name = ? WHERE id = ?",
2269                vec![json!("Alice"), json!(1)],
2270            ),
2271        );
2272        assert!(updated.contains("\"affected\":1"), "got: {updated}");
2273
2274        let deleted = handle(
2275            &rt,
2276            &query_request_with_params(6, "DELETE FROM qp WHERE name = ?", vec![json!("Alice")]),
2277        );
2278        assert!(deleted.contains("\"affected\":1"), "got: {deleted}");
2279
2280        let remaining = handle(&rt, &query_request(7, "SELECT * FROM qp"));
2281        assert!(result_rows(&remaining).is_empty(), "got: {remaining}");
2282    }
2283
2284    #[test]
2285    fn query_with_params_insert_and_search_round_trip() {
2286        let rt = make_runtime();
2287        let insert = handle(
2288            &rt,
2289            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"]}}"#,
2290        );
2291        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2292
2293        let search = handle(
2294            &rt,
2295            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SEARCH SIMILAR $1 COLLECTION bun_embeddings LIMIT 1","params":[[1.0,0.0]]}}"#,
2296        );
2297        assert!(search.contains("\"rows\""), "got: {search}");
2298        assert!(search.contains("\"score\":1"), "got: {search}");
2299        assert!(!search.contains("\"error\""), "got: {search}");
2300    }
2301
2302    #[test]
2303    fn query_with_question_vector_param_round_trips() {
2304        let rt = make_runtime();
2305        let insert = handle(
2306            &rt,
2307            &query_request_with_params(
2308                1,
2309                "INSERT INTO question_embeddings VECTOR (dense, content) VALUES (?, ?)",
2310                vec![json!([1.0, 0.0]), json!("question vector")],
2311            ),
2312        );
2313        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2314
2315        let search = handle(
2316            &rt,
2317            &query_request_with_params(
2318                2,
2319                "SEARCH SIMILAR ? COLLECTION question_embeddings LIMIT 1",
2320                vec![json!([1.0, 0.0])],
2321            ),
2322        );
2323        assert!(search.contains("\"rows\""), "got: {search}");
2324        assert!(search.contains("\"score\":1"), "got: {search}");
2325        assert!(!search.contains("\"error\""), "got: {search}");
2326    }
2327
2328    #[test]
2329    fn query_with_typed_json_rpc_params_round_trips() {
2330        let rt = make_runtime();
2331        let create = handle(
2332            &rt,
2333            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)"}}"#,
2334        );
2335        assert!(!create.contains("\"error\""), "got: {create}");
2336
2337        let insert = handle(
2338            &rt,
2339            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"}]}}"#,
2340        );
2341        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2342
2343        let selected = handle(
2344            &rt,
2345            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"SELECT * FROM value_params"}}"#,
2346        );
2347        assert!(selected.contains("\"ok\":true"), "got: {selected}");
2348        assert!(selected.contains("\"$float\":\"NaN\""), "got: {selected}");
2349        assert!(
2350            selected.contains("\"$bytes\":\"3q2+7w==\""),
2351            "got: {selected}"
2352        );
2353        assert!(
2354            selected.contains("\"body\":{\"a\":null,\"z\":[1,{\"a\":true}]}"),
2355            "got: {selected}"
2356        );
2357        assert!(
2358            selected.contains("\"$ts\":\"1700000000123456789\""),
2359            "got: {selected}"
2360        );
2361        assert!(
2362            selected.contains("\"$uuid\":\"00112233-4455-6677-8899-aabbccddeeff\""),
2363            "got: {selected}"
2364        );
2365    }
2366
2367    #[test]
2368    fn select_timeseries_tags_decodes_json_payload() {
2369        let rt = make_runtime();
2370        let create = handle(&rt, &query_request(1, "CREATE TIMESERIES ts1"));
2371        assert!(!create.contains("\"error\""), "got: {create}");
2372
2373        let insert = handle(
2374            &rt,
2375            &query_request(
2376                2,
2377                r#"INSERT INTO ts1 (metric, value, tags, timestamp) VALUES ('cpu', 85, '{"host":"a"}', 1000)"#,
2378            ),
2379        );
2380        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2381
2382        let selected = handle(&rt, &query_request(3, "SELECT tags FROM ts1"));
2383        assert!(!selected.contains("<json"), "got: {selected}");
2384        let response = json::from_str::<Value>(&selected).expect("response json");
2385        let tags = response
2386            .get("result")
2387            .and_then(|result| result.get("rows"))
2388            .and_then(Value::as_array)
2389            .and_then(|rows| rows.first())
2390            .and_then(|row| row.get("tags"))
2391            .expect("tags field");
2392        assert_eq!(tags, &json!({"host": "a"}));
2393    }
2394
2395    #[test]
2396    fn select_table_json_column_round_trips_after_single_parse() {
2397        let rt = make_runtime();
2398        let create = handle(&rt, &query_request(1, "CREATE TABLE docs (payload JSON)"));
2399        assert!(!create.contains("\"error\""), "got: {create}");
2400
2401        let original = r#"{"nested":{"items":[1,true,"x"],"object":{"k":"v"}}}"#;
2402        let insert_sql = format!("INSERT INTO docs (payload) VALUES ({original})");
2403        let insert = handle(&rt, &query_request(2, &insert_sql));
2404        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2405
2406        let selected = handle(&rt, &query_request(3, "SELECT payload FROM docs"));
2407        assert!(!selected.contains("<json"), "got: {selected}");
2408        let response = json::from_str::<Value>(&selected).expect("response json");
2409        let payload = response
2410            .get("result")
2411            .and_then(|result| result.get("rows"))
2412            .and_then(Value::as_array)
2413            .and_then(|rows| rows.first())
2414            .and_then(|row| row.get("payload"))
2415            .expect("payload field");
2416        let expected = json::from_str::<Value>(original).expect("expected json");
2417        assert_eq!(payload, &expected);
2418
2419        let payload_text = payload.to_string_compact();
2420        assert_eq!(
2421            json::from_str::<Value>(&payload_text).expect("single parse"),
2422            expected
2423        );
2424    }
2425
2426    #[test]
2427    fn select_json_corruption_falls_back_to_code_and_hex() {
2428        use crate::storage::query::unified::UnifiedResult;
2429
2430        let mut result = UnifiedResult::with_columns(vec!["payload".into()]);
2431        let mut record = UnifiedRecord::new();
2432        record.set("payload", SchemaValue::Json(b"{not json".to_vec()));
2433        result.push(record);
2434
2435        let json = query_result_to_json(&RuntimeQueryResult {
2436            query: "SELECT payload FROM docs".to_string(),
2437            mode: crate::storage::query::modes::QueryMode::Sql,
2438            statement: "select",
2439            engine: "runtime-table",
2440            result,
2441            affected_rows: 0,
2442            statement_type: "select",
2443            bookmark: None,
2444            notice: None,
2445        });
2446
2447        let payload = json
2448            .get("rows")
2449            .and_then(Value::as_array)
2450            .and_then(|rows| rows.first())
2451            .and_then(|row| row.get("payload"))
2452            .expect("payload field");
2453        assert_eq!(
2454            payload.get("code").and_then(Value::as_str),
2455            Some("INVALID_JSON")
2456        );
2457        assert_eq!(
2458            payload.get("hex").and_then(Value::as_str),
2459            Some("7b6e6f74206a736f6e")
2460        );
2461    }
2462
2463    #[test]
2464    fn json_value_to_schema_value_decodes_typed_envelopes() {
2465        let SchemaValue::Blob(bytes) = json_value_to_schema_value(&json!({ "$bytes": "AAECAw==" }))
2466        else {
2467            panic!("expected blob");
2468        };
2469        assert_eq!(bytes, vec![0, 1, 2, 3]);
2470
2471        assert_eq!(
2472            json_value_to_schema_value(&json!({ "$ts": "9223372036854775807" })),
2473            SchemaValue::Timestamp(i64::MAX)
2474        );
2475
2476        let SchemaValue::Uuid(bytes) = json_value_to_schema_value(&json!({
2477            "$uuid": "00112233-4455-6677-8899-aabbccddeeff"
2478        })) else {
2479            panic!("expected uuid");
2480        };
2481        assert_eq!(
2482            bytes,
2483            [
2484                0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
2485                0xee, 0xff
2486            ]
2487        );
2488
2489        let SchemaValue::Float(value) =
2490            json_value_to_schema_value(&json!({ "$float": "-Infinity" }))
2491        else {
2492            panic!("expected float");
2493        };
2494        assert!(value.is_infinite() && value.is_sign_negative());
2495
2496        assert_eq!(
2497            json_value_to_schema_value(&json!({ "$int": "9007199254740993" })),
2498            SchemaValue::Integer(9_007_199_254_740_993)
2499        );
2500        assert_eq!(
2501            json_value_to_schema_value(&json!({ "$uint": "9223372036854775808" })),
2502            SchemaValue::UnsignedInteger(i64::MAX as u64 + 1)
2503        );
2504        assert_eq!(
2505            json_value_to_schema_value(&json!({ "$decimal": "3.14159265358979323846" })),
2506            SchemaValue::DecimalText("3.14159265358979323846".to_string())
2507        );
2508    }
2509
2510    #[test]
2511    fn query_result_json_preserves_exact_numbers() {
2512        use crate::storage::query::unified::UnifiedResult;
2513
2514        let mut result =
2515            UnifiedResult::with_columns(vec!["big".into(), "unsigned".into(), "decimal".into()]);
2516        let mut record = UnifiedRecord::new();
2517        record.set("big", SchemaValue::Integer(9_007_199_254_740_993));
2518        record.set(
2519            "unsigned",
2520            SchemaValue::UnsignedInteger(i64::MAX as u64 + 1),
2521        );
2522        record.set(
2523            "decimal",
2524            SchemaValue::DecimalText("3.14159265358979323846".to_string()),
2525        );
2526        result.push(record);
2527
2528        let json = query_result_to_json(&RuntimeQueryResult {
2529            query: "SELECT exact".to_string(),
2530            mode: crate::storage::query::modes::QueryMode::Sql,
2531            statement: "select",
2532            engine: "runtime-table",
2533            result,
2534            affected_rows: 0,
2535            statement_type: "select",
2536            bookmark: None,
2537            notice: None,
2538        });
2539
2540        let row = json
2541            .get("rows")
2542            .and_then(Value::as_array)
2543            .and_then(|rows| rows.first())
2544            .expect("row");
2545        assert_eq!(
2546            row.get("big"),
2547            Some(&single_key_object(
2548                "$int",
2549                Value::String("9007199254740993".to_string())
2550            ))
2551        );
2552        assert_eq!(
2553            row.get("unsigned"),
2554            Some(&single_key_object(
2555                "$uint",
2556                Value::String("9223372036854775808".to_string())
2557            ))
2558        );
2559        assert_eq!(
2560            row.get("decimal"),
2561            Some(&single_key_object(
2562                "$decimal",
2563                Value::String("3.14159265358979323846".to_string())
2564            ))
2565        );
2566    }
2567
2568    #[test]
2569    fn query_with_params_arity_mismatch_rejected() {
2570        let rt = make_runtime();
2571        let _ = handle(
2572            &rt,
2573            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pa (id INTEGER)"}}"#,
2574        );
2575        let resp = handle(
2576            &rt,
2577            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pa WHERE id = $1","params":[1,2]}}"#,
2578        );
2579        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2580    }
2581
2582    #[test]
2583    fn query_with_question_params_arity_mismatch_rejected() {
2584        let rt = make_runtime();
2585        let _ = handle(&rt, &query_request(1, "CREATE TABLE qpa (id INTEGER)"));
2586        let resp = handle(
2587            &rt,
2588            &query_request_with_params(
2589                2,
2590                "SELECT * FROM qpa WHERE id = ?",
2591                vec![json!(1), json!(2)],
2592            ),
2593        );
2594        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2595        assert!(resp.contains("SQL expects 1, got 2"), "got: {resp}");
2596    }
2597
2598    #[test]
2599    fn query_with_params_gap_rejected() {
2600        let rt = make_runtime();
2601        let _ = handle(
2602            &rt,
2603            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pg (a INTEGER, b INTEGER)"}}"#,
2604        );
2605        let resp = handle(
2606            &rt,
2607            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pg WHERE a = $1 AND b = $3","params":[1,2,3]}}"#,
2608        );
2609        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2610    }
2611
2612    #[test]
2613    fn query_with_question_numbered_gap_rejected() {
2614        let rt = make_runtime();
2615        let _ = handle(&rt, &query_request(1, "CREATE TABLE qpg (id INTEGER)"));
2616        let resp = handle(
2617            &rt,
2618            &query_request_with_params(
2619                2,
2620                "SELECT * FROM qpg WHERE id = ?2",
2621                vec![json!(1), json!(2)],
2622            ),
2623        );
2624        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2625        assert!(resp.contains("parameter $`1` is missing"), "got: {resp}");
2626    }
2627
2628    #[test]
2629    fn query_with_question_params_type_mismatch_names_slot() {
2630        let rt = make_runtime();
2631        let _ = handle(&rt, &query_request(1, "CREATE TABLE qpt (id INTEGER)"));
2632        let resp = handle(
2633            &rt,
2634            &query_request_with_params(
2635                2,
2636                "INSERT INTO qpt (id) VALUES (?)",
2637                vec![json!("not-an-integer")],
2638            ),
2639        );
2640        assert!(resp.contains("\"QUERY_ERROR\""), "got: {resp}");
2641        assert!(resp.contains("id"), "got: {resp}");
2642        assert!(resp.contains("integer"), "got: {resp}");
2643    }
2644
2645    #[test]
2646    fn query_select_one_returns_rows() {
2647        let rt = make_runtime();
2648        let resp = handle(
2649            &rt,
2650            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT 1 AS one"}}"#,
2651        );
2652        assert!(resp.contains("\"result\""));
2653        assert!(!resp.contains("\"error\""));
2654    }
2655
2656    #[test]
2657    fn ask_query_result_uses_canonical_envelope() {
2658        use crate::storage::query::unified::UnifiedResult;
2659
2660        let mut result = UnifiedResult::with_columns(vec![
2661            "answer".into(),
2662            "provider".into(),
2663            "model".into(),
2664            "prompt_tokens".into(),
2665            "completion_tokens".into(),
2666            "sources_count".into(),
2667            "sources_flat".into(),
2668            "citations".into(),
2669            "validation".into(),
2670        ]);
2671        let mut record = UnifiedRecord::new();
2672        record.set("answer", SchemaValue::text("Deploy failed [^1]."));
2673        record.set("provider", SchemaValue::text("openai"));
2674        record.set("model", SchemaValue::text("gpt-4o-mini"));
2675        record.set("prompt_tokens", SchemaValue::Integer(11));
2676        record.set("completion_tokens", SchemaValue::Integer(7));
2677        record.set(
2678            "sources_flat",
2679            SchemaValue::Json(
2680                br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
2681            ),
2682        );
2683        record.set(
2684            "citations",
2685            SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
2686        );
2687        record.set(
2688            "validation",
2689            SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
2690        );
2691        result.push(record);
2692
2693        let json = query_result_to_json(&RuntimeQueryResult {
2694            query: "ASK 'why did deploy fail?'".to_string(),
2695            mode: crate::storage::query::modes::QueryMode::Sql,
2696            statement: "ask",
2697            engine: "runtime-ai",
2698            result,
2699            affected_rows: 0,
2700            statement_type: "select",
2701            bookmark: None,
2702            notice: None,
2703        });
2704
2705        assert_eq!(
2706            json.get("answer").and_then(Value::as_str),
2707            Some("Deploy failed [^1].")
2708        );
2709        assert_eq!(json.get("cache_hit").and_then(Value::as_bool), Some(false));
2710        assert_eq!(json.get("cost_usd").and_then(Value::as_f64), Some(0.0));
2711        assert_eq!(json.get("mode").and_then(Value::as_str), Some("strict"));
2712        assert_eq!(json.get("retry_count").and_then(Value::as_u64), Some(0));
2713        assert!(
2714            json.get("rows").is_none(),
2715            "ASK envelope must not be row-wrapped: {json}"
2716        );
2717        assert!(
2718            json.get("sources_flat")
2719                .and_then(Value::as_array)
2720                .is_some_and(|sources| sources.len() == 1
2721                    && sources[0].get("payload").and_then(Value::as_str).is_some()),
2722            "sources_flat must be a parsed array: {json}"
2723        );
2724        assert!(
2725            json.get("citations")
2726                .and_then(Value::as_array)
2727                .is_some_and(|citations| citations.len() == 1),
2728            "citations must be a parsed array: {json}"
2729        );
2730        assert_eq!(
2731            json.get("validation")
2732                .and_then(|v| v.get("ok"))
2733                .and_then(Value::as_bool),
2734            Some(true)
2735        );
2736    }
2737
2738    // -----------------------------------------------------------------
2739    // Transaction tests
2740    // -----------------------------------------------------------------
2741
2742    #[test]
2743    fn tx_begin_returns_tx_id_and_isolation() {
2744        let rt = make_runtime();
2745        with_session(&rt, |call, _| {
2746            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2747            assert!(resp.contains("\"tx_id\":1"));
2748            assert!(resp.contains("\"isolation\":\"read_committed_deferred\""));
2749            assert!(!resp.contains("\"error\""));
2750        });
2751    }
2752
2753    #[test]
2754    fn tx_begin_twice_returns_already_open() {
2755        let rt = make_runtime();
2756        with_session(&rt, |call, _| {
2757            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2758            let resp = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
2759            assert!(resp.contains("\"code\":\"TX_ALREADY_OPEN\""));
2760        });
2761    }
2762
2763    #[test]
2764    fn tx_commit_without_begin_returns_no_tx_open() {
2765        let rt = make_runtime();
2766        with_session(&rt, |call, _| {
2767            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.commit","params":null}"#);
2768            assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2769        });
2770    }
2771
2772    #[test]
2773    fn tx_rollback_without_begin_returns_no_tx_open() {
2774        let rt = make_runtime();
2775        with_session(&rt, |call, _| {
2776            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.rollback","params":null}"#);
2777            assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2778        });
2779    }
2780
2781    #[test]
2782    fn insert_inside_tx_returns_pending_envelope() {
2783        let rt = make_runtime();
2784        // Create the collection first (outside any tx).
2785        let _ = handle(
2786            &rt,
2787            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE users (name TEXT)"}}"#,
2788        );
2789        with_session(&rt, |call, _| {
2790            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2791            let resp = call(
2792                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"users","payload":{"name":"alice"}}}"#,
2793            );
2794            assert!(resp.contains("\"pending\":true"));
2795            assert!(resp.contains("\"tx_id\":1"));
2796            assert!(resp.contains("\"affected\":0"));
2797        });
2798    }
2799
2800    #[test]
2801    fn begin_insert_rollback_does_not_persist() {
2802        let rt = make_runtime();
2803        let _ = handle(
2804            &rt,
2805            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u (name TEXT)"}}"#,
2806        );
2807        with_session(&rt, |call, _| {
2808            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2809            let _ = call(
2810                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u","payload":{"name":"ghost"}}}"#,
2811            );
2812            let rollback = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
2813            assert!(rollback.contains("\"ops_discarded\":1"));
2814            assert!(rollback.contains("\"tx_id\":1"));
2815        });
2816        // After rollback, the row must not be visible to a fresh query.
2817        let resp = handle(
2818            &rt,
2819            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u"}}"#,
2820        );
2821        assert!(!resp.contains("\"ghost\""));
2822    }
2823
2824    #[test]
2825    fn begin_insert_commit_persists() {
2826        let rt = make_runtime();
2827        let _ = handle(
2828            &rt,
2829            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u2 (name TEXT)"}}"#,
2830        );
2831        with_session(&rt, |call, _| {
2832            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2833            let _ = call(
2834                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u2","payload":{"name":"alice"}}}"#,
2835            );
2836            let _ = call(
2837                r#"{"jsonrpc":"2.0","id":3,"method":"insert","params":{"collection":"u2","payload":{"name":"bob"}}}"#,
2838            );
2839            let commit = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.commit","params":null}"#);
2840            assert!(commit.contains("\"ops_replayed\":2"));
2841            assert!(!commit.contains("\"error\""));
2842        });
2843        let resp = handle(
2844            &rt,
2845            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u2"}}"#,
2846        );
2847        assert!(resp.contains("\"alice\""));
2848        assert!(resp.contains("\"bob\""));
2849    }
2850
2851    #[test]
2852    fn bulk_insert_inside_tx_buffers_everything() {
2853        let rt = make_runtime();
2854        let _ = handle(
2855            &rt,
2856            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u3 (name TEXT)"}}"#,
2857        );
2858        with_session(&rt, |call, _| {
2859            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2860            let resp = call(
2861                r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"u3","payloads":[{"name":"a"},{"name":"b"},{"name":"c"}]}}"#,
2862            );
2863            assert!(resp.contains("\"buffered\":3"));
2864            assert!(resp.contains("\"pending\":true"));
2865            assert!(resp.contains("\"affected\":0"));
2866
2867            let commit = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2868            assert!(commit.contains("\"ops_replayed\":3"));
2869        });
2870    }
2871
2872    #[test]
2873    fn bulk_insert_chunks_at_internal_500_row_limit() {
2874        assert_eq!(bulk_insert_chunk_count(0), 0);
2875        assert_eq!(bulk_insert_chunk_count(1), 1);
2876        assert_eq!(bulk_insert_chunk_count(500), 1);
2877        assert_eq!(bulk_insert_chunk_count(501), 2);
2878        assert_eq!(bulk_insert_chunk_count(1000), 2);
2879        assert_eq!(bulk_insert_chunk_count(1001), 3);
2880    }
2881
2882    proptest! {
2883        #![proptest_config(ProptestConfig {
2884            cases: 12,
2885            ..ProptestConfig::default()
2886        })]
2887
2888        #[test]
2889        fn bulk_insert_matches_sequential_insert_state(
2890            names in proptest::collection::vec("[a-z]{1,8}", 1usize..20)
2891        ) {
2892            let rt = make_runtime();
2893            let payloads = names
2894                .iter()
2895                .map(|name| format!(r#"{{"name":"{name}","kind":"bulk"}}"#))
2896                .collect::<Vec<_>>();
2897            let payload_array = payloads.join(",");
2898
2899            let bulk = handle(
2900                &rt,
2901                &format!(
2902                    r#"{{"jsonrpc":"2.0","id":1,"method":"bulk_insert","params":{{"collection":"bulk_prop","payloads":[{payload_array}]}}}}"#
2903                ),
2904            );
2905            let bulk_result = json::from_str::<Value>(&bulk).expect("bulk json");
2906            let bulk_ids = bulk_result
2907                .get("result")
2908                .and_then(|result| result.get("ids"))
2909                .and_then(Value::as_array)
2910                .expect("bulk ids");
2911            prop_assert_eq!(bulk_ids.len(), names.len());
2912
2913            for (index, payload) in payloads.iter().enumerate() {
2914                let insert = handle(
2915                    &rt,
2916                    &format!(
2917                        r#"{{"jsonrpc":"2.0","id":{},"method":"insert","params":{{"collection":"seq_prop","payload":{payload}}}}}"#,
2918                        index + 10
2919                    ),
2920                );
2921                let insert_result = json::from_str::<Value>(&insert).expect("insert json");
2922                prop_assert!(
2923                    insert_result
2924                        .get("result")
2925                        .and_then(|result| result.get("id"))
2926                        .is_some(),
2927                    "insert response missing id: {insert}"
2928                );
2929            }
2930
2931            let bulk_rows = result_name_kind(&handle(
2932                &rt,
2933                r#"{"jsonrpc":"2.0","id":99,"method":"query","params":{"sql":"SELECT name, kind FROM bulk_prop ORDER BY rid"}}"#,
2934            ));
2935            let seq_rows = result_name_kind(&handle(
2936                &rt,
2937                r#"{"jsonrpc":"2.0","id":100,"method":"query","params":{"sql":"SELECT name, kind FROM seq_prop ORDER BY rid"}}"#,
2938            ));
2939            prop_assert_eq!(bulk_rows, seq_rows);
2940        }
2941
2942        #[test]
2943        fn question_param_select_matches_inlined_literal(value in json_scalar_param()) {
2944            let rt = make_runtime();
2945            let bound = handle(
2946                &rt,
2947                &query_request_with_params(1, "SELECT ? AS v", vec![value.clone()]),
2948            );
2949            let inline_sql = format!("SELECT {} AS v", sql_literal_for_json(&value));
2950            let inlined = handle(&rt, &query_request(2, &inline_sql));
2951            prop_assert_eq!(
2952                result_rows(&bound),
2953                result_rows(&inlined),
2954                "bound={}, inlined={}",
2955                bound,
2956                inlined
2957            );
2958        }
2959    }
2960
2961    #[test]
2962    fn bulk_insert_graph_nodes_accepts_flat_rows_and_returns_ids() {
2963        let rt = make_runtime();
2964        create_graph_collection(&rt, "social");
2965
2966        let resp = handle(
2967            &rt,
2968            r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"social","payloads":[{"label":"User","name":"alice"},{"label":"User","name":"bob"}]}}"#,
2969        );
2970        let envelope: Value = json::from_str(&resp).expect("json response");
2971        let result = envelope.get("result").expect("result");
2972        assert_eq!(result.get("affected").and_then(Value::as_u64), Some(2));
2973        assert_eq!(
2974            result
2975                .get("ids")
2976                .and_then(Value::as_array)
2977                .map(|ids| ids.len()),
2978            Some(2)
2979        );
2980
2981        let query = handle(
2982            &rt,
2983            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"MATCH (n:User) RETURN n.name"}}"#,
2984        );
2985        assert!(query.contains("\"alice\""), "got: {query}");
2986        assert!(query.contains("\"bob\""), "got: {query}");
2987    }
2988
2989    #[test]
2990    fn bulk_insert_graph_edges_accepts_flat_rows_and_returns_ids() {
2991        let rt = make_runtime();
2992        create_graph_collection(&rt, "network");
2993        let nodes = handle(
2994            &rt,
2995            r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"network","payloads":[{"label":"Host","name":"app"},{"label":"Host","name":"db"}]}}"#,
2996        );
2997        let envelope: Value = json::from_str(&nodes).expect("node response");
2998        let ids = envelope
2999            .get("result")
3000            .and_then(|r| r.get("ids"))
3001            .and_then(Value::as_array)
3002            .expect("node ids");
3003        let from = ids[0].as_u64().expect("from id");
3004        let to = ids[1].as_u64().expect("to id");
3005
3006        let resp = handle(
3007            &rt,
3008            &format!(
3009                r#"{{"jsonrpc":"2.0","id":3,"method":"bulk_insert","params":{{"collection":"network","payloads":[{{"label":"connects","from":{from},"to":{to},"weight":0.5,"role":"primary"}}]}}}}"#
3010            ),
3011        );
3012        let envelope: Value = json::from_str(&resp).expect("edge response");
3013        let result = envelope.get("result").expect("result");
3014        assert_eq!(result.get("affected").and_then(Value::as_u64), Some(1));
3015        assert_eq!(
3016            result
3017                .get("ids")
3018                .and_then(Value::as_array)
3019                .map(|ids| ids.len()),
3020            Some(1)
3021        );
3022    }
3023
3024    #[test]
3025    fn delete_inside_tx_is_buffered() {
3026        let rt = make_runtime();
3027        // Seed two rows outside any tx.
3028        let _ = handle(
3029            &rt,
3030            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u4 (name TEXT)"}}"#,
3031        );
3032        let _ = handle(
3033            &rt,
3034            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO u4 (name) VALUES ('keep')"}}"#,
3035        );
3036        with_session(&rt, |call, _| {
3037            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
3038            let resp = call(
3039                r#"{"jsonrpc":"2.0","id":2,"method":"delete","params":{"collection":"u4","id":"1"}}"#,
3040            );
3041            assert!(resp.contains("\"pending\":true"));
3042            let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
3043        });
3044        // Row should still be present after rollback of the delete.
3045        let resp = handle(
3046            &rt,
3047            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u4"}}"#,
3048        );
3049        assert!(resp.contains("\"keep\""));
3050    }
3051
3052    #[test]
3053    fn close_with_open_tx_auto_rollbacks() {
3054        let rt = make_runtime();
3055        let _ = handle(
3056            &rt,
3057            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u5 (name TEXT)"}}"#,
3058        );
3059        with_session(&rt, |call, _| {
3060            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
3061            let _ = call(
3062                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u5","payload":{"name":"ghost"}}}"#,
3063            );
3064            let close = call(r#"{"jsonrpc":"2.0","id":3,"method":"close","params":null}"#);
3065            assert!(close.contains("\"__close__\":true"));
3066            assert!(!close.contains("\"error\""));
3067        });
3068        let resp = handle(
3069            &rt,
3070            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u5"}}"#,
3071        );
3072        assert!(!resp.contains("\"ghost\""));
3073    }
3074
3075    // -----------------------------------------------------------------
3076    // Cursor streaming tests
3077    // -----------------------------------------------------------------
3078
3079    fn seed_numbers_table(rt: &RedDBRuntime, table: &str, count: u32) {
3080        let _ = handle(
3081            rt,
3082            &format!(
3083                r#"{{"jsonrpc":"2.0","id":1,"method":"query","params":{{"sql":"CREATE TABLE {table} (n INTEGER)"}}}}"#,
3084            ),
3085        );
3086        for i in 0..count {
3087            let _ = handle(
3088                rt,
3089                &format!(
3090                    r#"{{"jsonrpc":"2.0","id":2,"method":"query","params":{{"sql":"INSERT INTO {table} (n) VALUES ({i})"}}}}"#,
3091                ),
3092            );
3093        }
3094    }
3095
3096    #[test]
3097    fn cursor_open_returns_id_columns_and_total() {
3098        let rt = make_runtime();
3099        seed_numbers_table(&rt, "nums1", 3);
3100        with_session(&rt, |call, _| {
3101            let resp = call(
3102                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums1"}}"#,
3103            );
3104            assert!(resp.contains("\"cursor_id\":1"));
3105            assert!(resp.contains("\"total_rows\":3"));
3106            assert!(resp.contains("\"columns\""));
3107            assert!(!resp.contains("\"error\""));
3108        });
3109    }
3110
3111    #[test]
3112    fn cursor_next_chunks_rows_and_signals_done() {
3113        let rt = make_runtime();
3114        seed_numbers_table(&rt, "nums2", 5);
3115        with_session(&rt, |call, _| {
3116            let _ = call(
3117                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums2"}}"#,
3118            );
3119            let first = call(
3120                r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
3121            );
3122            assert!(first.contains("\"done\":false"));
3123            assert!(first.contains("\"remaining\":3"));
3124
3125            let second = call(
3126                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
3127            );
3128            assert!(second.contains("\"done\":false"));
3129            assert!(second.contains("\"remaining\":1"));
3130
3131            let third = call(
3132                r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
3133            );
3134            assert!(third.contains("\"done\":true"));
3135            assert!(third.contains("\"remaining\":0"));
3136        });
3137    }
3138
3139    #[test]
3140    fn cursor_auto_drops_when_exhausted() {
3141        let rt = make_runtime();
3142        seed_numbers_table(&rt, "nums3", 2);
3143        with_session(&rt, |call, _| {
3144            let _ = call(
3145                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums3"}}"#,
3146            );
3147            let _ = call(
3148                r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
3149            );
3150            // Cursor was auto-dropped after done=true; subsequent next
3151            // must error with CURSOR_NOT_FOUND.
3152            let resp = call(
3153                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
3154            );
3155            assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
3156        });
3157    }
3158
3159    #[test]
3160    fn cursor_close_removes_it() {
3161        let rt = make_runtime();
3162        seed_numbers_table(&rt, "nums4", 3);
3163        with_session(&rt, |call, _| {
3164            let _ = call(
3165                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums4"}}"#,
3166            );
3167            let close =
3168                call(r#"{"jsonrpc":"2.0","id":2,"method":"query.close","params":{"cursor_id":1}}"#);
3169            assert!(close.contains("\"closed\":true"));
3170            let after = call(
3171                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
3172            );
3173            assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
3174        });
3175    }
3176
3177    #[test]
3178    fn cursor_close_unknown_errors() {
3179        let rt = make_runtime();
3180        with_session(&rt, |call, _| {
3181            let resp = call(
3182                r#"{"jsonrpc":"2.0","id":1,"method":"query.close","params":{"cursor_id":9999}}"#,
3183            );
3184            assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
3185        });
3186    }
3187
3188    #[test]
3189    fn cursor_next_without_cursor_id_errors() {
3190        let rt = make_runtime();
3191        with_session(&rt, |call, _| {
3192            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"query.next","params":{}}"#);
3193            assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
3194        });
3195    }
3196
3197    #[test]
3198    fn cursor_default_batch_size_returns_all_when_smaller_than_default() {
3199        let rt = make_runtime();
3200        seed_numbers_table(&rt, "nums5", 7);
3201        with_session(&rt, |call, _| {
3202            let _ = call(
3203                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums5"}}"#,
3204            );
3205            // No batch_size → default 100, table has 7 rows, all in one call.
3206            let resp =
3207                call(r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1}}"#);
3208            assert!(resp.contains("\"done\":true"));
3209            assert!(resp.contains("\"remaining\":0"));
3210        });
3211    }
3212
3213    #[test]
3214    fn close_method_drops_open_cursors() {
3215        let rt = make_runtime();
3216        seed_numbers_table(&rt, "nums6", 3);
3217        // Single session: open a cursor, call close, verify cursor is gone by reopening
3218        // fresh session and attempting to use cursor_id 1.
3219        with_session(&rt, |call, _| {
3220            let _ = call(
3221                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums6"}}"#,
3222            );
3223            let close = call(r#"{"jsonrpc":"2.0","id":2,"method":"close","params":null}"#);
3224            assert!(close.contains("\"__close__\":true"));
3225            // Cursor must be gone after close within the same session.
3226            let after = call(
3227                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
3228            );
3229            assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
3230        });
3231    }
3232
3233    #[test]
3234    fn cursor_independent_of_transaction_state() {
3235        let rt = make_runtime();
3236        seed_numbers_table(&rt, "nums7", 4);
3237        with_session(&rt, |call, _| {
3238            // Open cursor, begin tx, commit tx — cursor survives.
3239            let _ = call(
3240                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums7"}}"#,
3241            );
3242            let _ = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
3243            let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
3244            let resp = call(
3245                r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
3246            );
3247            assert!(resp.contains("\"done\":true"));
3248            assert!(!resp.contains("\"error\""));
3249        });
3250    }
3251
3252    #[test]
3253    fn second_tx_after_commit_gets_fresh_id() {
3254        let rt = make_runtime();
3255        let _ = handle(
3256            &rt,
3257            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u6 (name TEXT)"}}"#,
3258        );
3259        with_session(&rt, |call, _| {
3260            let first = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
3261            assert!(first.contains("\"tx_id\":1"));
3262            let _ = call(
3263                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u6","payload":{"name":"x"}}}"#,
3264            );
3265            let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
3266
3267            let second = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.begin","params":null}"#);
3268            assert!(second.contains("\"tx_id\":2"));
3269            let _ = call(r#"{"jsonrpc":"2.0","id":5,"method":"tx.rollback","params":null}"#);
3270        });
3271    }
3272
3273    #[test]
3274    fn prepare_and_execute_prepared_statement() {
3275        let rt = make_runtime();
3276        // Create table + insert a row
3277        let _ = handle(
3278            &rt,
3279            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE ps_test (n INTEGER)"}}"#,
3280        );
3281        let _ = handle(
3282            &rt,
3283            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO ps_test (n) VALUES (42)"}}"#,
3284        );
3285
3286        with_session(&rt, |call, _| {
3287            // Prepare a parameterized SELECT.
3288            let prep = call(
3289                r#"{"jsonrpc":"2.0","id":3,"method":"prepare","params":{"sql":"SELECT n FROM ps_test WHERE n = 42"}}"#,
3290            );
3291            assert!(prep.contains("\"prepared_id\""), "prepare response: {prep}");
3292
3293            // Extract the prepared_id.
3294            let id: u64 = {
3295                let v: crate::json::Value = crate::json::from_str(&prep).expect("json");
3296                let result = v.get("result").expect("result");
3297                result
3298                    .get("prepared_id")
3299                    .and_then(|n| n.as_f64())
3300                    .expect("prepared_id") as u64
3301            };
3302
3303            // Execute with the bind value for the parameterized literal.
3304            let exec = call(&format!(
3305                r#"{{"jsonrpc":"2.0","id":4,"method":"execute_prepared","params":{{"prepared_id":{id},"binds":[42]}}}}"#
3306            ));
3307            // Response uses "rows" key (see query_result_to_json).
3308            assert!(
3309                exec.contains("\"rows\""),
3310                "execute_prepared response: {exec}"
3311            );
3312            assert!(exec.contains("42"), "expected row with n=42 in: {exec}");
3313        });
3314    }
3315}