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            description: None,
1695            description_generated: false,
1696            declared_model: crate::catalog::CollectionModel::Graph,
1697            schema_mode: crate::catalog::SchemaMode::Dynamic,
1698            origin: crate::physical::ContractOrigin::Explicit,
1699            version: 1,
1700            created_at_unix_ms: now,
1701            updated_at_unix_ms: now,
1702            default_ttl_ms: None,
1703            vector_dimension: None,
1704            vector_metric: None,
1705            context_index_fields: Vec::new(),
1706            declared_columns: Vec::new(),
1707            table_def: None,
1708            timestamps_enabled: false,
1709            context_index_enabled: false,
1710            metrics_raw_retention_ms: None,
1711            metrics_rollup_policies: Vec::new(),
1712            metrics_tenant_identity: None,
1713            metrics_namespace: None,
1714            append_only: false,
1715            subscriptions: Vec::new(),
1716            analytics_config: Vec::new(),
1717            session_key: None,
1718            session_gap_ms: None,
1719            retention_duration_ms: None,
1720            analytical_storage: None,
1721
1722            ai_policy: None,
1723        })
1724        .expect("save graph contract");
1725    }
1726
1727    fn handle(rt: &RedDBRuntime, line: &str) -> String {
1728        let mut session = Session::new();
1729        handle_line(&Backend::Local(rt), &mut session, line)
1730    }
1731
1732    fn query_request(id: u64, sql: &str) -> String {
1733        let mut params = json::Map::new();
1734        params.insert("sql".to_string(), Value::String(sql.to_string()));
1735
1736        let mut request = json::Map::new();
1737        request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1738        request.insert("id".to_string(), Value::Number(id as f64));
1739        request.insert("method".to_string(), Value::String("query".to_string()));
1740        request.insert("params".to_string(), Value::Object(params));
1741        Value::Object(request).to_string_compact()
1742    }
1743
1744    fn query_request_with_params(id: u64, sql: &str, binds: Vec<Value>) -> String {
1745        let mut params = json::Map::new();
1746        params.insert("sql".to_string(), Value::String(sql.to_string()));
1747        params.insert("params".to_string(), Value::Array(binds));
1748
1749        let mut request = json::Map::new();
1750        request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1751        request.insert("id".to_string(), Value::Number(id as f64));
1752        request.insert("method".to_string(), Value::String("query".to_string()));
1753        request.insert("params".to_string(), Value::Object(params));
1754        Value::Object(request).to_string_compact()
1755    }
1756
1757    /// Stateful helper: keeps the same `Session` across multiple calls so
1758    /// tests can exercise multi-step transaction flows in a single closure.
1759    fn with_session<F>(rt: &RedDBRuntime, f: F)
1760    where
1761        F: FnOnce(&dyn Fn(&str) -> String, &RedDBRuntime),
1762    {
1763        let session = std::cell::RefCell::new(Session::new());
1764        let call = |line: &str| -> String {
1765            let mut s = session.borrow_mut();
1766            handle_line(&Backend::Local(rt), &mut s, line)
1767        };
1768        f(&call, rt);
1769    }
1770
1771    fn result_rows(response: &str) -> Vec<Value> {
1772        json::from_str::<Value>(response)
1773            .expect("json response")
1774            .get("result")
1775            .and_then(|result| result.get("rows"))
1776            .and_then(Value::as_array)
1777            .map(|rows| rows.to_vec())
1778            .unwrap_or_default()
1779    }
1780
1781    fn result_name_kind(response: &str) -> Vec<(String, String)> {
1782        result_rows(response)
1783            .into_iter()
1784            .map(|row| {
1785                let object = row.as_object().expect("row object");
1786                let name = object
1787                    .get("name")
1788                    .and_then(Value::as_str)
1789                    .expect("row name")
1790                    .to_string();
1791                let kind = object
1792                    .get("kind")
1793                    .and_then(Value::as_str)
1794                    .expect("row kind")
1795                    .to_string();
1796                (name, kind)
1797            })
1798            .collect()
1799    }
1800
1801    fn json_scalar_param() -> impl Strategy<Value = Value> {
1802        prop_oneof![
1803            Just(Value::Null),
1804            any::<bool>().prop_map(Value::Bool),
1805            (-1000_i64..1000_i64).prop_map(|n| Value::Number(n as f64)),
1806            "[a-z']{0,8}".prop_map(Value::String),
1807        ]
1808    }
1809
1810    fn sql_literal_for_json(value: &Value) -> String {
1811        match value {
1812            Value::Null => "NULL".to_string(),
1813            Value::Bool(true) => "TRUE".to_string(),
1814            Value::Bool(false) => "FALSE".to_string(),
1815            Value::Number(n) => format!("{n:.0}"),
1816            Value::String(s) => format!("'{}'", s.replace('\'', "''")),
1817            _ => panic!("unsupported scalar param: {value:?}"),
1818        }
1819    }
1820
1821    #[test]
1822    fn version_method_returns_version_and_protocol() {
1823        let rt = make_runtime();
1824        let line = r#"{"jsonrpc":"2.0","id":1,"method":"version","params":{}}"#;
1825        let resp = handle(&rt, line);
1826        assert!(resp.contains("\"id\":1"));
1827        assert!(resp.contains("\"protocol\":\"1.0\""));
1828        assert!(resp.contains("\"version\""));
1829    }
1830
1831    #[test]
1832    fn health_method_returns_ok_true() {
1833        let rt = make_runtime();
1834        let resp = handle(
1835            &rt,
1836            r#"{"jsonrpc":"2.0","id":"abc","method":"health","params":{}}"#,
1837        );
1838        assert!(resp.contains("\"ok\":true"));
1839        assert!(resp.contains("\"id\":\"abc\""));
1840    }
1841
1842    #[test]
1843    fn parse_error_for_invalid_json() {
1844        let rt = make_runtime();
1845        let resp = handle(&rt, "not json {");
1846        assert!(resp.contains("\"code\":\"PARSE_ERROR\""));
1847        assert!(resp.contains("\"id\":null"));
1848    }
1849
1850    #[test]
1851    fn invalid_request_when_method_missing() {
1852        let rt = make_runtime();
1853        let resp = handle(&rt, r#"{"jsonrpc":"2.0","id":1,"params":{}}"#);
1854        assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
1855    }
1856
1857    #[test]
1858    fn unknown_method_is_invalid_request() {
1859        let rt = make_runtime();
1860        let resp = handle(
1861            &rt,
1862            r#"{"jsonrpc":"2.0","id":1,"method":"frobnicate","params":{}}"#,
1863        );
1864        assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
1865        assert!(resp.contains("frobnicate"));
1866    }
1867
1868    #[test]
1869    fn invalid_params_when_query_sql_missing() {
1870        let rt = make_runtime();
1871        let resp = handle(
1872            &rt,
1873            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{}}"#,
1874        );
1875        assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
1876    }
1877
1878    #[test]
1879    fn query_params_reject_superseded_exact_number_envelope() {
1880        let rt = make_runtime();
1881        let resp = handle(
1882            &rt,
1883            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT $1","params":[{"$number":"1"}]}}"#,
1884        );
1885        assert!(resp.contains("\"code\":\"INVALID_PARAMS\""), "got: {resp}");
1886        assert!(
1887            resp.contains("superseded exact-number envelope"),
1888            "got: {resp}"
1889        );
1890    }
1891
1892    #[test]
1893    fn embedded_session_serves_the_stdio_protocol_without_a_process() {
1894        let rt = std::sync::Arc::new(make_runtime());
1895        let mut a = EmbeddedRpcSession::new(rt.clone());
1896        let mut b = EmbeddedRpcSession::new(rt);
1897
1898        let (resp, closed) = a.handle(r#"{"jsonrpc":"2.0","id":1,"method":"version","params":{}}"#);
1899        assert!(resp.contains("\"result\""), "got: {resp}");
1900        assert!(!closed);
1901
1902        let (resp, _) = a.handle(
1903            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"CREATE TABLE emb (id INTEGER, name TEXT)"}}"#,
1904        );
1905        assert!(resp.contains("\"result\""), "got: {resp}");
1906        let (resp, _) = a.handle(
1907            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"INSERT INTO emb (id, name) VALUES (1, 'x')"}}"#,
1908        );
1909        assert!(resp.contains("\"result\""), "got: {resp}");
1910
1911        // A second session over the same engine sees the committed row.
1912        let (resp, _) = b.handle(
1913            r#"{"jsonrpc":"2.0","id":4,"method":"query","params":{"sql":"SELECT id, name FROM emb"}}"#,
1914        );
1915        assert!(resp.contains("\"x\""), "got: {resp}");
1916
1917        let (resp, closed) = a.handle(r#"{"jsonrpc":"2.0","id":5,"method":"close","params":{}}"#);
1918        assert!(closed, "got: {resp}");
1919    }
1920
1921    #[test]
1922    fn embedded_session_transactions_are_scoped_to_the_session() {
1923        let rt = std::sync::Arc::new(make_runtime());
1924        let mut a = EmbeddedRpcSession::new(rt.clone());
1925        let mut b = EmbeddedRpcSession::new(rt);
1926        let _ = a.handle(
1927            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE emb_tx (id INTEGER)"}}"#,
1928        );
1929
1930        let (begin, _) = a.handle(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":{}}"#);
1931        assert!(begin.contains("\"result\""), "got: {begin}");
1932        // `a` has a transaction open; `b` is an independent session and may open its own.
1933        let (begin_b, _) = b.handle(r#"{"jsonrpc":"2.0","id":3,"method":"tx.begin","params":{}}"#);
1934        assert!(begin_b.contains("\"result\""), "got: {begin_b}");
1935        let (again, _) = a.handle(r#"{"jsonrpc":"2.0","id":4,"method":"tx.begin","params":{}}"#);
1936        assert!(again.contains("TX_ALREADY_OPEN"), "got: {again}");
1937    }
1938
1939    #[test]
1940    fn close_method_marks_response_for_shutdown() {
1941        let rt = make_runtime();
1942        let resp = handle(
1943            &rt,
1944            r#"{"jsonrpc":"2.0","id":1,"method":"close","params":{}}"#,
1945        );
1946        assert!(resp.contains("\"__close__\":true"));
1947    }
1948
1949    #[test]
1950    fn query_with_int_text_params_round_trips() {
1951        let rt = make_runtime();
1952        let _ = handle(
1953            &rt,
1954            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE p (id INTEGER, name TEXT)"}}"#,
1955        );
1956        let _ = handle(
1957            &rt,
1958            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (1, 'Alice')"}}"#,
1959        );
1960        let _ = handle(
1961            &rt,
1962            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (2, 'Bob')"}}"#,
1963        );
1964        let resp = handle(
1965            &rt,
1966            r#"{"jsonrpc":"2.0","id":4,"method":"query","params":{"sql":"SELECT * FROM p WHERE id = $1 AND name = $2","params":[1,"Alice"]}}"#,
1967        );
1968        assert!(resp.contains("\"Alice\""), "got: {resp}");
1969        assert!(!resp.contains("\"Bob\""), "got: {resp}");
1970    }
1971
1972    #[test]
1973    fn query_with_question_params_covers_select_insert_update_delete() {
1974        let rt = make_runtime();
1975        let create = handle(
1976            &rt,
1977            &query_request(1, "CREATE TABLE qp (id INTEGER, name TEXT)"),
1978        );
1979        assert!(!create.contains("\"error\""), "got: {create}");
1980
1981        let inserted = handle(
1982            &rt,
1983            &query_request_with_params(
1984                2,
1985                "INSERT INTO qp (id, name) VALUES (?, ?)",
1986                vec![json!(1), json!("O'Reilly")],
1987            ),
1988        );
1989        assert!(inserted.contains("\"affected\":1"), "got: {inserted}");
1990
1991        let selected = handle(
1992            &rt,
1993            &query_request_with_params(3, "SELECT name FROM qp WHERE id = ?", vec![json!(1)]),
1994        );
1995        let rows = result_rows(&selected);
1996        assert_eq!(rows.len(), 1, "got: {selected}");
1997        assert_eq!(
1998            rows[0].get("name").and_then(Value::as_str),
1999            Some("O'Reilly")
2000        );
2001
2002        let selected_numbered = handle(
2003            &rt,
2004            &query_request_with_params(
2005                4,
2006                "SELECT name FROM qp WHERE name = ?1 AND id = ?2",
2007                vec![json!("O'Reilly"), json!(1)],
2008            ),
2009        );
2010        assert_eq!(
2011            result_rows(&selected_numbered).len(),
2012            1,
2013            "got: {selected_numbered}"
2014        );
2015
2016        let updated = handle(
2017            &rt,
2018            &query_request_with_params(
2019                5,
2020                "UPDATE qp SET name = ? WHERE id = ?",
2021                vec![json!("Alice"), json!(1)],
2022            ),
2023        );
2024        assert!(updated.contains("\"affected\":1"), "got: {updated}");
2025
2026        let deleted = handle(
2027            &rt,
2028            &query_request_with_params(6, "DELETE FROM qp WHERE name = ?", vec![json!("Alice")]),
2029        );
2030        assert!(deleted.contains("\"affected\":1"), "got: {deleted}");
2031
2032        let remaining = handle(&rt, &query_request(7, "SELECT * FROM qp"));
2033        assert!(result_rows(&remaining).is_empty(), "got: {remaining}");
2034    }
2035
2036    #[test]
2037    fn query_with_params_insert_and_search_round_trip() {
2038        let rt = make_runtime();
2039        let insert = handle(
2040            &rt,
2041            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"]}}"#,
2042        );
2043        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2044
2045        let search = handle(
2046            &rt,
2047            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SEARCH SIMILAR $1 COLLECTION bun_embeddings LIMIT 1","params":[[1.0,0.0]]}}"#,
2048        );
2049        assert!(search.contains("\"rows\""), "got: {search}");
2050        assert!(search.contains("\"score\":1"), "got: {search}");
2051        assert!(!search.contains("\"error\""), "got: {search}");
2052    }
2053
2054    #[test]
2055    fn query_with_question_vector_param_round_trips() {
2056        let rt = make_runtime();
2057        let insert = handle(
2058            &rt,
2059            &query_request_with_params(
2060                1,
2061                "INSERT INTO question_embeddings VECTOR (dense, content) VALUES (?, ?)",
2062                vec![json!([1.0, 0.0]), json!("question vector")],
2063            ),
2064        );
2065        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2066
2067        let search = handle(
2068            &rt,
2069            &query_request_with_params(
2070                2,
2071                "SEARCH SIMILAR ? COLLECTION question_embeddings LIMIT 1",
2072                vec![json!([1.0, 0.0])],
2073            ),
2074        );
2075        assert!(search.contains("\"rows\""), "got: {search}");
2076        assert!(search.contains("\"score\":1"), "got: {search}");
2077        assert!(!search.contains("\"error\""), "got: {search}");
2078    }
2079
2080    #[test]
2081    fn query_with_typed_json_rpc_params_round_trips() {
2082        let rt = make_runtime();
2083        let create = handle(
2084            &rt,
2085            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)"}}"#,
2086        );
2087        assert!(!create.contains("\"error\""), "got: {create}");
2088
2089        let insert = handle(
2090            &rt,
2091            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"}]}}"#,
2092        );
2093        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2094
2095        let selected = handle(
2096            &rt,
2097            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"SELECT * FROM value_params"}}"#,
2098        );
2099        assert!(selected.contains("\"ok\":true"), "got: {selected}");
2100        assert!(selected.contains("\"$float\":\"NaN\""), "got: {selected}");
2101        assert!(
2102            selected.contains("\"$bytes\":\"3q2+7w==\""),
2103            "got: {selected}"
2104        );
2105        assert!(
2106            selected.contains("\"body\":{\"a\":null,\"z\":[1,{\"a\":true}]}"),
2107            "got: {selected}"
2108        );
2109        assert!(
2110            selected.contains("\"$ts\":\"1700000000123456789\""),
2111            "got: {selected}"
2112        );
2113        assert!(
2114            selected.contains("\"$uuid\":\"00112233-4455-6677-8899-aabbccddeeff\""),
2115            "got: {selected}"
2116        );
2117    }
2118
2119    #[test]
2120    fn select_timeseries_tags_decodes_json_payload() {
2121        let rt = make_runtime();
2122        let create = handle(&rt, &query_request(1, "CREATE TIMESERIES ts1"));
2123        assert!(!create.contains("\"error\""), "got: {create}");
2124
2125        let insert = handle(
2126            &rt,
2127            &query_request(
2128                2,
2129                r#"INSERT INTO ts1 (metric, value, tags, timestamp) VALUES ('cpu', 85, '{"host":"a"}', 1000)"#,
2130            ),
2131        );
2132        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2133
2134        let selected = handle(&rt, &query_request(3, "SELECT tags FROM ts1"));
2135        assert!(!selected.contains("<json"), "got: {selected}");
2136        let response = json::from_str::<Value>(&selected).expect("response json");
2137        let tags = response
2138            .get("result")
2139            .and_then(|result| result.get("rows"))
2140            .and_then(Value::as_array)
2141            .and_then(|rows| rows.first())
2142            .and_then(|row| row.get("tags"))
2143            .expect("tags field");
2144        assert_eq!(tags, &json!({"host": "a"}));
2145    }
2146
2147    #[test]
2148    fn select_table_json_column_round_trips_after_single_parse() {
2149        let rt = make_runtime();
2150        let create = handle(&rt, &query_request(1, "CREATE TABLE docs (payload JSON)"));
2151        assert!(!create.contains("\"error\""), "got: {create}");
2152
2153        let original = r#"{"nested":{"items":[1,true,"x"],"object":{"k":"v"}}}"#;
2154        let insert_sql = format!("INSERT INTO docs (payload) VALUES ({original})");
2155        let insert = handle(&rt, &query_request(2, &insert_sql));
2156        assert!(insert.contains("\"affected\":1"), "got: {insert}");
2157
2158        let selected = handle(&rt, &query_request(3, "SELECT payload FROM docs"));
2159        assert!(!selected.contains("<json"), "got: {selected}");
2160        let response = json::from_str::<Value>(&selected).expect("response json");
2161        let payload = response
2162            .get("result")
2163            .and_then(|result| result.get("rows"))
2164            .and_then(Value::as_array)
2165            .and_then(|rows| rows.first())
2166            .and_then(|row| row.get("payload"))
2167            .expect("payload field");
2168        let expected = json::from_str::<Value>(original).expect("expected json");
2169        assert_eq!(payload, &expected);
2170
2171        let payload_text = payload.to_string_compact();
2172        assert_eq!(
2173            json::from_str::<Value>(&payload_text).expect("single parse"),
2174            expected
2175        );
2176    }
2177
2178    #[test]
2179    fn select_json_corruption_falls_back_to_code_and_hex() {
2180        use crate::storage::query::unified::UnifiedResult;
2181
2182        let mut result = UnifiedResult::with_columns(vec!["payload".into()]);
2183        let mut record = UnifiedRecord::new();
2184        record.set("payload", SchemaValue::Json(b"{not json".to_vec()));
2185        result.push(record);
2186
2187        let json = query_result_to_json(&RuntimeQueryResult {
2188            query: "SELECT payload FROM docs".to_string(),
2189            mode: reddb_rql::modes::QueryMode::Sql,
2190            statement: "select",
2191            engine: "runtime-table",
2192            result,
2193            affected_rows: 0,
2194            statement_type: "select",
2195            bookmark: None,
2196            notice: None,
2197        });
2198
2199        let payload = json
2200            .get("rows")
2201            .and_then(Value::as_array)
2202            .and_then(|rows| rows.first())
2203            .and_then(|row| row.get("payload"))
2204            .expect("payload field");
2205        assert_eq!(
2206            payload.get("code").and_then(Value::as_str),
2207            Some("INVALID_JSON")
2208        );
2209        assert_eq!(
2210            payload.get("hex").and_then(Value::as_str),
2211            Some("7b6e6f74206a736f6e")
2212        );
2213    }
2214
2215    #[test]
2216    fn query_result_json_preserves_exact_numbers() {
2217        use crate::storage::query::unified::UnifiedResult;
2218
2219        let mut result =
2220            UnifiedResult::with_columns(vec!["big".into(), "unsigned".into(), "decimal".into()]);
2221        let mut record = UnifiedRecord::new();
2222        record.set("big", SchemaValue::Integer(9_007_199_254_740_993));
2223        record.set(
2224            "unsigned",
2225            SchemaValue::UnsignedInteger(i64::MAX as u64 + 1),
2226        );
2227        record.set(
2228            "decimal",
2229            SchemaValue::DecimalText("3.14159265358979323846".to_string()),
2230        );
2231        result.push(record);
2232
2233        let json = query_result_to_json(&RuntimeQueryResult {
2234            query: "SELECT exact".to_string(),
2235            mode: reddb_rql::modes::QueryMode::Sql,
2236            statement: "select",
2237            engine: "runtime-table",
2238            result,
2239            affected_rows: 0,
2240            statement_type: "select",
2241            bookmark: None,
2242            notice: None,
2243        });
2244
2245        let row = json
2246            .get("rows")
2247            .and_then(Value::as_array)
2248            .and_then(|rows| rows.first())
2249            .expect("row");
2250        assert_eq!(
2251            row.get("big"),
2252            Some(&single_key_object(
2253                "$int",
2254                Value::String("9007199254740993".to_string())
2255            ))
2256        );
2257        assert_eq!(
2258            row.get("unsigned"),
2259            Some(&single_key_object(
2260                "$uint",
2261                Value::String("9223372036854775808".to_string())
2262            ))
2263        );
2264        assert_eq!(
2265            row.get("decimal"),
2266            Some(&single_key_object(
2267                "$decimal",
2268                Value::String("3.14159265358979323846".to_string())
2269            ))
2270        );
2271    }
2272
2273    #[test]
2274    fn query_with_params_arity_mismatch_rejected() {
2275        let rt = make_runtime();
2276        let _ = handle(
2277            &rt,
2278            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pa (id INTEGER)"}}"#,
2279        );
2280        let resp = handle(
2281            &rt,
2282            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pa WHERE id = $1","params":[1,2]}}"#,
2283        );
2284        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2285    }
2286
2287    #[test]
2288    fn query_with_question_params_arity_mismatch_rejected() {
2289        let rt = make_runtime();
2290        let _ = handle(&rt, &query_request(1, "CREATE TABLE qpa (id INTEGER)"));
2291        let resp = handle(
2292            &rt,
2293            &query_request_with_params(
2294                2,
2295                "SELECT * FROM qpa WHERE id = ?",
2296                vec![json!(1), json!(2)],
2297            ),
2298        );
2299        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2300        assert!(resp.contains("SQL expects 1, got 2"), "got: {resp}");
2301    }
2302
2303    #[test]
2304    fn query_with_params_gap_rejected() {
2305        let rt = make_runtime();
2306        let _ = handle(
2307            &rt,
2308            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pg (a INTEGER, b INTEGER)"}}"#,
2309        );
2310        let resp = handle(
2311            &rt,
2312            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pg WHERE a = $1 AND b = $3","params":[1,2,3]}}"#,
2313        );
2314        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2315    }
2316
2317    #[test]
2318    fn query_with_question_numbered_gap_rejected() {
2319        let rt = make_runtime();
2320        let _ = handle(&rt, &query_request(1, "CREATE TABLE qpg (id INTEGER)"));
2321        let resp = handle(
2322            &rt,
2323            &query_request_with_params(
2324                2,
2325                "SELECT * FROM qpg WHERE id = ?2",
2326                vec![json!(1), json!(2)],
2327            ),
2328        );
2329        assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2330        assert!(resp.contains("parameter $`1` is missing"), "got: {resp}");
2331    }
2332
2333    #[test]
2334    fn query_with_question_params_type_mismatch_names_slot() {
2335        let rt = make_runtime();
2336        let _ = handle(&rt, &query_request(1, "CREATE TABLE qpt (id INTEGER)"));
2337        let resp = handle(
2338            &rt,
2339            &query_request_with_params(
2340                2,
2341                "INSERT INTO qpt (id) VALUES (?)",
2342                vec![json!("not-an-integer")],
2343            ),
2344        );
2345        assert!(resp.contains("\"QUERY_ERROR\""), "got: {resp}");
2346        assert!(resp.contains("id"), "got: {resp}");
2347        assert!(resp.contains("integer"), "got: {resp}");
2348    }
2349
2350    #[test]
2351    fn query_select_one_returns_rows() {
2352        let rt = make_runtime();
2353        let resp = handle(
2354            &rt,
2355            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT 1 AS one"}}"#,
2356        );
2357        assert!(resp.contains("\"result\""));
2358        assert!(!resp.contains("\"error\""));
2359    }
2360
2361    #[test]
2362    fn ask_query_result_uses_canonical_envelope() {
2363        use crate::storage::query::unified::UnifiedResult;
2364
2365        let mut result = UnifiedResult::with_columns(vec![
2366            "answer".into(),
2367            "provider".into(),
2368            "model".into(),
2369            "prompt_tokens".into(),
2370            "completion_tokens".into(),
2371            "sources_count".into(),
2372            "sources_flat".into(),
2373            "citations".into(),
2374            "validation".into(),
2375        ]);
2376        let mut record = UnifiedRecord::new();
2377        record.set("answer", SchemaValue::text("Deploy failed [^1]."));
2378        record.set("provider", SchemaValue::text("openai"));
2379        record.set("model", SchemaValue::text("gpt-4o-mini"));
2380        record.set("prompt_tokens", SchemaValue::Integer(11));
2381        record.set("completion_tokens", SchemaValue::Integer(7));
2382        record.set(
2383            "sources_flat",
2384            SchemaValue::Json(
2385                br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
2386            ),
2387        );
2388        record.set(
2389            "citations",
2390            SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
2391        );
2392        record.set(
2393            "validation",
2394            SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
2395        );
2396        result.push(record);
2397
2398        let json = query_result_to_json(&RuntimeQueryResult {
2399            query: "ASK 'why did deploy fail?'".to_string(),
2400            mode: reddb_rql::modes::QueryMode::Sql,
2401            statement: "ask",
2402            engine: "runtime-ai",
2403            result,
2404            affected_rows: 0,
2405            statement_type: "select",
2406            bookmark: None,
2407            notice: None,
2408        });
2409
2410        assert_eq!(
2411            json.get("answer").and_then(Value::as_str),
2412            Some("Deploy failed [^1].")
2413        );
2414        assert_eq!(json.get("cache_hit").and_then(Value::as_bool), Some(false));
2415        assert_eq!(json.get("cost_usd").and_then(Value::as_f64), Some(0.0));
2416        assert_eq!(json.get("mode").and_then(Value::as_str), Some("strict"));
2417        assert_eq!(json.get("retry_count").and_then(Value::as_u64), Some(0));
2418        assert!(
2419            json.get("rows").is_none(),
2420            "ASK envelope must not be row-wrapped: {json}"
2421        );
2422        assert!(
2423            json.get("sources_flat")
2424                .and_then(Value::as_array)
2425                .is_some_and(|sources| sources.len() == 1
2426                    && sources[0].get("payload").and_then(Value::as_str).is_some()),
2427            "sources_flat must be a parsed array: {json}"
2428        );
2429        assert!(
2430            json.get("citations")
2431                .and_then(Value::as_array)
2432                .is_some_and(|citations| citations.len() == 1),
2433            "citations must be a parsed array: {json}"
2434        );
2435        assert_eq!(
2436            json.get("validation")
2437                .and_then(|v| v.get("ok"))
2438                .and_then(Value::as_bool),
2439            Some(true)
2440        );
2441    }
2442
2443    // -----------------------------------------------------------------
2444    // Transaction tests
2445    // -----------------------------------------------------------------
2446
2447    #[test]
2448    fn tx_begin_returns_tx_id_and_isolation() {
2449        let rt = make_runtime();
2450        with_session(&rt, |call, _| {
2451            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2452            assert!(resp.contains("\"tx_id\":1"));
2453            assert!(resp.contains("\"isolation\":\"read_committed_deferred\""));
2454            assert!(!resp.contains("\"error\""));
2455        });
2456    }
2457
2458    #[test]
2459    fn tx_begin_twice_returns_already_open() {
2460        let rt = make_runtime();
2461        with_session(&rt, |call, _| {
2462            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2463            let resp = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
2464            assert!(resp.contains("\"code\":\"TX_ALREADY_OPEN\""));
2465        });
2466    }
2467
2468    #[test]
2469    fn tx_commit_without_begin_returns_no_tx_open() {
2470        let rt = make_runtime();
2471        with_session(&rt, |call, _| {
2472            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.commit","params":null}"#);
2473            assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2474        });
2475    }
2476
2477    #[test]
2478    fn tx_rollback_without_begin_returns_no_tx_open() {
2479        let rt = make_runtime();
2480        with_session(&rt, |call, _| {
2481            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.rollback","params":null}"#);
2482            assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2483        });
2484    }
2485
2486    #[test]
2487    fn insert_inside_tx_returns_pending_envelope() {
2488        let rt = make_runtime();
2489        // Create the collection first (outside any tx).
2490        let _ = handle(
2491            &rt,
2492            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE users (name TEXT)"}}"#,
2493        );
2494        with_session(&rt, |call, _| {
2495            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2496            let resp = call(
2497                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"users","payload":{"name":"alice"}}}"#,
2498            );
2499            assert!(resp.contains("\"pending\":true"));
2500            assert!(resp.contains("\"tx_id\":1"));
2501            assert!(resp.contains("\"affected\":0"));
2502        });
2503    }
2504
2505    #[test]
2506    fn begin_insert_rollback_does_not_persist() {
2507        let rt = make_runtime();
2508        let _ = handle(
2509            &rt,
2510            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u (name TEXT)"}}"#,
2511        );
2512        with_session(&rt, |call, _| {
2513            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2514            let _ = call(
2515                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u","payload":{"name":"ghost"}}}"#,
2516            );
2517            let rollback = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
2518            assert!(rollback.contains("\"ops_discarded\":1"));
2519            assert!(rollback.contains("\"tx_id\":1"));
2520        });
2521        // After rollback, the row must not be visible to a fresh query.
2522        let resp = handle(
2523            &rt,
2524            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u"}}"#,
2525        );
2526        assert!(!resp.contains("\"ghost\""));
2527    }
2528
2529    #[test]
2530    fn begin_insert_commit_persists() {
2531        let rt = make_runtime();
2532        let _ = handle(
2533            &rt,
2534            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u2 (name TEXT)"}}"#,
2535        );
2536        with_session(&rt, |call, _| {
2537            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2538            let _ = call(
2539                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u2","payload":{"name":"alice"}}}"#,
2540            );
2541            let _ = call(
2542                r#"{"jsonrpc":"2.0","id":3,"method":"insert","params":{"collection":"u2","payload":{"name":"bob"}}}"#,
2543            );
2544            let commit = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.commit","params":null}"#);
2545            assert!(commit.contains("\"ops_replayed\":2"));
2546            assert!(!commit.contains("\"error\""));
2547        });
2548        let resp = handle(
2549            &rt,
2550            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u2"}}"#,
2551        );
2552        assert!(resp.contains("\"alice\""));
2553        assert!(resp.contains("\"bob\""));
2554    }
2555
2556    #[test]
2557    fn bulk_insert_inside_tx_buffers_everything() {
2558        let rt = make_runtime();
2559        let _ = handle(
2560            &rt,
2561            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u3 (name TEXT)"}}"#,
2562        );
2563        with_session(&rt, |call, _| {
2564            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2565            let resp = call(
2566                r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"u3","payloads":[{"name":"a"},{"name":"b"},{"name":"c"}]}}"#,
2567            );
2568            assert!(resp.contains("\"buffered\":3"));
2569            assert!(resp.contains("\"pending\":true"));
2570            assert!(resp.contains("\"affected\":0"));
2571
2572            let commit = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2573            assert!(commit.contains("\"ops_replayed\":3"));
2574        });
2575    }
2576
2577    #[test]
2578    fn bulk_insert_chunks_at_internal_500_row_limit() {
2579        assert_eq!(bulk_insert_chunk_count(0), 0);
2580        assert_eq!(bulk_insert_chunk_count(1), 1);
2581        assert_eq!(bulk_insert_chunk_count(500), 1);
2582        assert_eq!(bulk_insert_chunk_count(501), 2);
2583        assert_eq!(bulk_insert_chunk_count(1000), 2);
2584        assert_eq!(bulk_insert_chunk_count(1001), 3);
2585    }
2586
2587    proptest! {
2588        #![proptest_config(ProptestConfig {
2589            cases: 12,
2590            ..ProptestConfig::default()
2591        })]
2592
2593        #[test]
2594        fn bulk_insert_matches_sequential_insert_state(
2595            names in proptest::collection::vec("[a-z]{1,8}", 1usize..20)
2596        ) {
2597            let rt = make_runtime();
2598            let payloads = names
2599                .iter()
2600                .map(|name| format!(r#"{{"name":"{name}","kind":"bulk"}}"#))
2601                .collect::<Vec<_>>();
2602            let payload_array = payloads.join(",");
2603
2604            let bulk = handle(
2605                &rt,
2606                &format!(
2607                    r#"{{"jsonrpc":"2.0","id":1,"method":"bulk_insert","params":{{"collection":"bulk_prop","payloads":[{payload_array}]}}}}"#
2608                ),
2609            );
2610            let bulk_result = json::from_str::<Value>(&bulk).expect("bulk json");
2611            let bulk_ids = bulk_result
2612                .get("result")
2613                .and_then(|result| result.get("ids"))
2614                .and_then(Value::as_array)
2615                .expect("bulk ids");
2616            prop_assert_eq!(bulk_ids.len(), names.len());
2617
2618            for (index, payload) in payloads.iter().enumerate() {
2619                let insert = handle(
2620                    &rt,
2621                    &format!(
2622                        r#"{{"jsonrpc":"2.0","id":{},"method":"insert","params":{{"collection":"seq_prop","payload":{payload}}}}}"#,
2623                        index + 10
2624                    ),
2625                );
2626                let insert_result = json::from_str::<Value>(&insert).expect("insert json");
2627                prop_assert!(
2628                    insert_result
2629                        .get("result")
2630                        .and_then(|result| result.get("id"))
2631                        .is_some(),
2632                    "insert response missing id: {insert}"
2633                );
2634            }
2635
2636            let bulk_rows = result_name_kind(&handle(
2637                &rt,
2638                r#"{"jsonrpc":"2.0","id":99,"method":"query","params":{"sql":"SELECT name, kind FROM bulk_prop ORDER BY rid"}}"#,
2639            ));
2640            let seq_rows = result_name_kind(&handle(
2641                &rt,
2642                r#"{"jsonrpc":"2.0","id":100,"method":"query","params":{"sql":"SELECT name, kind FROM seq_prop ORDER BY rid"}}"#,
2643            ));
2644            prop_assert_eq!(bulk_rows, seq_rows);
2645        }
2646
2647        #[test]
2648        fn question_param_select_matches_inlined_literal(value in json_scalar_param()) {
2649            let rt = make_runtime();
2650            let bound = handle(
2651                &rt,
2652                &query_request_with_params(1, "SELECT ? AS v", vec![value.clone()]),
2653            );
2654            let inline_sql = format!("SELECT {} AS v", sql_literal_for_json(&value));
2655            let inlined = handle(&rt, &query_request(2, &inline_sql));
2656            prop_assert_eq!(
2657                result_rows(&bound),
2658                result_rows(&inlined),
2659                "bound={}, inlined={}",
2660                bound,
2661                inlined
2662            );
2663        }
2664    }
2665
2666    #[test]
2667    fn bulk_insert_graph_nodes_accepts_flat_rows_and_returns_ids() {
2668        let rt = make_runtime();
2669        create_graph_collection(&rt, "social");
2670
2671        let resp = handle(
2672            &rt,
2673            r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"social","payloads":[{"label":"User","name":"alice"},{"label":"User","name":"bob"}]}}"#,
2674        );
2675        let envelope: Value = json::from_str(&resp).expect("json response");
2676        let result = envelope.get("result").expect("result");
2677        assert_eq!(result.get("affected").and_then(Value::as_u64), Some(2));
2678        assert_eq!(
2679            result
2680                .get("ids")
2681                .and_then(Value::as_array)
2682                .map(|ids| ids.len()),
2683            Some(2)
2684        );
2685
2686        let query = handle(
2687            &rt,
2688            r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"MATCH (n:User) RETURN n.name"}}"#,
2689        );
2690        assert!(query.contains("\"alice\""), "got: {query}");
2691        assert!(query.contains("\"bob\""), "got: {query}");
2692    }
2693
2694    #[test]
2695    fn bulk_insert_graph_edges_accepts_flat_rows_and_returns_ids() {
2696        let rt = make_runtime();
2697        create_graph_collection(&rt, "network");
2698        let nodes = handle(
2699            &rt,
2700            r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"network","payloads":[{"label":"Host","name":"app"},{"label":"Host","name":"db"}]}}"#,
2701        );
2702        let envelope: Value = json::from_str(&nodes).expect("node response");
2703        let ids = envelope
2704            .get("result")
2705            .and_then(|r| r.get("ids"))
2706            .and_then(Value::as_array)
2707            .expect("node ids");
2708        let from = ids[0].as_u64().expect("from id");
2709        let to = ids[1].as_u64().expect("to id");
2710
2711        let resp = handle(
2712            &rt,
2713            &format!(
2714                r#"{{"jsonrpc":"2.0","id":3,"method":"bulk_insert","params":{{"collection":"network","payloads":[{{"label":"connects","from":{from},"to":{to},"weight":0.5,"role":"primary"}}]}}}}"#
2715            ),
2716        );
2717        let envelope: Value = json::from_str(&resp).expect("edge response");
2718        let result = envelope.get("result").expect("result");
2719        assert_eq!(result.get("affected").and_then(Value::as_u64), Some(1));
2720        assert_eq!(
2721            result
2722                .get("ids")
2723                .and_then(Value::as_array)
2724                .map(|ids| ids.len()),
2725            Some(1)
2726        );
2727    }
2728
2729    #[test]
2730    fn delete_inside_tx_is_buffered() {
2731        let rt = make_runtime();
2732        // Seed two rows outside any tx.
2733        let _ = handle(
2734            &rt,
2735            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u4 (name TEXT)"}}"#,
2736        );
2737        let _ = handle(
2738            &rt,
2739            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO u4 (name) VALUES ('keep')"}}"#,
2740        );
2741        with_session(&rt, |call, _| {
2742            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2743            let resp = call(
2744                r#"{"jsonrpc":"2.0","id":2,"method":"delete","params":{"collection":"u4","id":"1"}}"#,
2745            );
2746            assert!(resp.contains("\"pending\":true"));
2747            let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
2748        });
2749        // Row should still be present after rollback of the delete.
2750        let resp = handle(
2751            &rt,
2752            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u4"}}"#,
2753        );
2754        assert!(resp.contains("\"keep\""));
2755    }
2756
2757    #[test]
2758    fn close_with_open_tx_auto_rollbacks() {
2759        let rt = make_runtime();
2760        let _ = handle(
2761            &rt,
2762            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u5 (name TEXT)"}}"#,
2763        );
2764        with_session(&rt, |call, _| {
2765            let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2766            let _ = call(
2767                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u5","payload":{"name":"ghost"}}}"#,
2768            );
2769            let close = call(r#"{"jsonrpc":"2.0","id":3,"method":"close","params":null}"#);
2770            assert!(close.contains("\"__close__\":true"));
2771            assert!(!close.contains("\"error\""));
2772        });
2773        let resp = handle(
2774            &rt,
2775            r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u5"}}"#,
2776        );
2777        assert!(!resp.contains("\"ghost\""));
2778    }
2779
2780    // -----------------------------------------------------------------
2781    // Cursor streaming tests
2782    // -----------------------------------------------------------------
2783
2784    fn seed_numbers_table(rt: &RedDBRuntime, table: &str, count: u32) {
2785        let _ = handle(
2786            rt,
2787            &format!(
2788                r#"{{"jsonrpc":"2.0","id":1,"method":"query","params":{{"sql":"CREATE TABLE {table} (n INTEGER)"}}}}"#,
2789            ),
2790        );
2791        for i in 0..count {
2792            let _ = handle(
2793                rt,
2794                &format!(
2795                    r#"{{"jsonrpc":"2.0","id":2,"method":"query","params":{{"sql":"INSERT INTO {table} (n) VALUES ({i})"}}}}"#,
2796                ),
2797            );
2798        }
2799    }
2800
2801    #[test]
2802    fn cursor_open_returns_id_columns_and_total() {
2803        let rt = make_runtime();
2804        seed_numbers_table(&rt, "nums1", 3);
2805        with_session(&rt, |call, _| {
2806            let resp = call(
2807                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums1"}}"#,
2808            );
2809            assert!(resp.contains("\"cursor_id\":1"));
2810            assert!(resp.contains("\"total_rows\":3"));
2811            assert!(resp.contains("\"columns\""));
2812            assert!(!resp.contains("\"error\""));
2813        });
2814    }
2815
2816    #[test]
2817    fn cursor_next_chunks_rows_and_signals_done() {
2818        let rt = make_runtime();
2819        seed_numbers_table(&rt, "nums2", 5);
2820        with_session(&rt, |call, _| {
2821            let _ = call(
2822                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums2"}}"#,
2823            );
2824            let first = call(
2825                r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2826            );
2827            assert!(first.contains("\"done\":false"));
2828            assert!(first.contains("\"remaining\":3"));
2829
2830            let second = call(
2831                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2832            );
2833            assert!(second.contains("\"done\":false"));
2834            assert!(second.contains("\"remaining\":1"));
2835
2836            let third = call(
2837                r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2838            );
2839            assert!(third.contains("\"done\":true"));
2840            assert!(third.contains("\"remaining\":0"));
2841        });
2842    }
2843
2844    #[test]
2845    fn cursor_auto_drops_when_exhausted() {
2846        let rt = make_runtime();
2847        seed_numbers_table(&rt, "nums3", 2);
2848        with_session(&rt, |call, _| {
2849            let _ = call(
2850                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums3"}}"#,
2851            );
2852            let _ = call(
2853                r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
2854            );
2855            // Cursor was auto-dropped after done=true; subsequent next
2856            // must error with CURSOR_NOT_FOUND.
2857            let resp = call(
2858                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
2859            );
2860            assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2861        });
2862    }
2863
2864    #[test]
2865    fn cursor_close_removes_it() {
2866        let rt = make_runtime();
2867        seed_numbers_table(&rt, "nums4", 3);
2868        with_session(&rt, |call, _| {
2869            let _ = call(
2870                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums4"}}"#,
2871            );
2872            let close =
2873                call(r#"{"jsonrpc":"2.0","id":2,"method":"query.close","params":{"cursor_id":1}}"#);
2874            assert!(close.contains("\"closed\":true"));
2875            let after = call(
2876                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
2877            );
2878            assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2879        });
2880    }
2881
2882    #[test]
2883    fn cursor_close_unknown_errors() {
2884        let rt = make_runtime();
2885        with_session(&rt, |call, _| {
2886            let resp = call(
2887                r#"{"jsonrpc":"2.0","id":1,"method":"query.close","params":{"cursor_id":9999}}"#,
2888            );
2889            assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2890        });
2891    }
2892
2893    #[test]
2894    fn cursor_next_without_cursor_id_errors() {
2895        let rt = make_runtime();
2896        with_session(&rt, |call, _| {
2897            let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"query.next","params":{}}"#);
2898            assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
2899        });
2900    }
2901
2902    #[test]
2903    fn cursor_default_batch_size_returns_all_when_smaller_than_default() {
2904        let rt = make_runtime();
2905        seed_numbers_table(&rt, "nums5", 7);
2906        with_session(&rt, |call, _| {
2907            let _ = call(
2908                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums5"}}"#,
2909            );
2910            // No batch_size → default 100, table has 7 rows, all in one call.
2911            let resp =
2912                call(r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1}}"#);
2913            assert!(resp.contains("\"done\":true"));
2914            assert!(resp.contains("\"remaining\":0"));
2915        });
2916    }
2917
2918    #[test]
2919    fn close_method_drops_open_cursors() {
2920        let rt = make_runtime();
2921        seed_numbers_table(&rt, "nums6", 3);
2922        // Single session: open a cursor, call close, verify cursor is gone by reopening
2923        // fresh session and attempting to use cursor_id 1.
2924        with_session(&rt, |call, _| {
2925            let _ = call(
2926                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums6"}}"#,
2927            );
2928            let close = call(r#"{"jsonrpc":"2.0","id":2,"method":"close","params":null}"#);
2929            assert!(close.contains("\"__close__\":true"));
2930            // Cursor must be gone after close within the same session.
2931            let after = call(
2932                r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
2933            );
2934            assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2935        });
2936    }
2937
2938    #[test]
2939    fn cursor_independent_of_transaction_state() {
2940        let rt = make_runtime();
2941        seed_numbers_table(&rt, "nums7", 4);
2942        with_session(&rt, |call, _| {
2943            // Open cursor, begin tx, commit tx — cursor survives.
2944            let _ = call(
2945                r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums7"}}"#,
2946            );
2947            let _ = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
2948            let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2949            let resp = call(
2950                r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
2951            );
2952            assert!(resp.contains("\"done\":true"));
2953            assert!(!resp.contains("\"error\""));
2954        });
2955    }
2956
2957    #[test]
2958    fn second_tx_after_commit_gets_fresh_id() {
2959        let rt = make_runtime();
2960        let _ = handle(
2961            &rt,
2962            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u6 (name TEXT)"}}"#,
2963        );
2964        with_session(&rt, |call, _| {
2965            let first = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2966            assert!(first.contains("\"tx_id\":1"));
2967            let _ = call(
2968                r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u6","payload":{"name":"x"}}}"#,
2969            );
2970            let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2971
2972            let second = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.begin","params":null}"#);
2973            assert!(second.contains("\"tx_id\":2"));
2974            let _ = call(r#"{"jsonrpc":"2.0","id":5,"method":"tx.rollback","params":null}"#);
2975        });
2976    }
2977
2978    #[test]
2979    fn prepare_and_execute_prepared_statement() {
2980        let rt = make_runtime();
2981        // Create table + insert a row
2982        let _ = handle(
2983            &rt,
2984            r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE ps_test (n INTEGER)"}}"#,
2985        );
2986        let _ = handle(
2987            &rt,
2988            r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO ps_test (n) VALUES (42)"}}"#,
2989        );
2990
2991        with_session(&rt, |call, _| {
2992            // Prepare a parameterized SELECT.
2993            let prep = call(
2994                r#"{"jsonrpc":"2.0","id":3,"method":"prepare","params":{"sql":"SELECT n FROM ps_test WHERE n = 42"}}"#,
2995            );
2996            assert!(prep.contains("\"prepared_id\""), "prepare response: {prep}");
2997
2998            // Extract the prepared_id.
2999            let id: u64 = {
3000                let v: crate::json::Value = crate::json::from_str(&prep).expect("json");
3001                let result = v.get("result").expect("result");
3002                result
3003                    .get("prepared_id")
3004                    .and_then(|n| n.as_f64())
3005                    .expect("prepared_id") as u64
3006            };
3007
3008            // Execute with the bind value for the parameterized literal.
3009            let exec = call(&format!(
3010                r#"{{"jsonrpc":"2.0","id":4,"method":"execute_prepared","params":{{"prepared_id":{id},"binds":[42]}}}}"#
3011            ));
3012            // Response uses "rows" key (see query_result_to_json).
3013            assert!(
3014                exec.contains("\"rows\""),
3015                "execute_prepared response: {exec}"
3016            );
3017            assert!(exec.contains("42"), "expected row with n=42 in: {exec}");
3018        });
3019    }
3020}