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