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