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