Skip to main content

reddb_server/wire/redwire/
session.rs

1//! Per-connection RedWire session: handshake → frame loop → bye.
2//!
3//! Dispatches the full RedWire frame set:
4//!   - Hello / AuthResponse (handshake only — once)
5//!   - Query / BulkInsert / Get / Delete (data plane)
6//!   - QueryBinary / BulkInsertBinary / BulkInsertPrevalidated
7//!     (binary fast paths)
8//!   - BulkStreamStart/Rows/Commit (streaming bulk)
9//!   - Prepare / ExecutePrepared (prepared statements)
10//!   - Ping / Pong / Bye (lifecycle)
11
12use std::io;
13use std::sync::Arc;
14
15use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
16use tokio::sync::{mpsc, Mutex as TokioMutex};
17
18use crate::auth::store::AuthStore;
19use crate::auth::Role;
20use crate::runtime::RedDBRuntime;
21use crate::serde_json::{self, Value as JsonValue};
22use reddb_wire::query_with_params::{
23    decode_query_with_params_request, ParamValue as RedWireParamValue, FEATURE_PARAMS,
24};
25use reddb_wire::redwire::operations::{
26    decode_delete_payload, decode_get_payload, decode_insert_dispatch_payload,
27    encode_bulk_ok_payload_from_json_id_literals, encode_delete_ok_payload,
28    encode_get_result_payload, encode_query_result_summary_payload,
29};
30
31use super::auth::{build_auth_ok, pick_auth_method, validate_auth_response, AuthOutcome};
32use super::validate_minor_version;
33use reddb_wire::redwire::handshake::{
34    build_auth_fail_payload, build_auth_ok_frame_from_payload, build_hello_ack_frame,
35    expect_auth_response_payload, parse_auth_response_oauth_jwt, Hello,
36};
37use reddb_wire::redwire::{
38    build_dispatch_reply_frame, build_error_frame_lossy, build_reply_frame,
39    choose_hello_minor_version, decode_frame, encode_frame, read_frame_async,
40    rewrap_length_prefixed_handler_response, Frame, MessageDirection, MessageKind, REDWIRE_MAGIC,
41};
42
43#[derive(Debug)]
44struct AuthedSession {
45    username: String,
46    role: Role,
47    tenant: Option<String>,
48    #[allow(dead_code)]
49    session_id: String,
50}
51
52pub async fn handle_session<S>(
53    mut stream: S,
54    runtime: Arc<RedDBRuntime>,
55    auth_store: Option<Arc<AuthStore>>,
56    oauth: Option<Arc<crate::auth::oauth::OAuthValidator>>,
57) -> io::Result<()>
58where
59    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
60{
61    // Discriminator byte was already consumed by the service-router
62    // detector when it dispatched here. If callers wire this from
63    // a non-router path they must consume it themselves first.
64    let session = perform_handshake(
65        &mut stream,
66        runtime.as_ref(),
67        auth_store.as_deref(),
68        oauth.as_deref(),
69    )
70    .await?;
71    if session.is_none() {
72        return Ok(());
73    }
74    let session = session.unwrap();
75
76    // Per-connection state for prepared statements + streaming
77    // bulk inserts. Owned by the session; dropped on disconnect.
78    let mut stream_session: Option<crate::wire::listener::BulkStreamSession> = None;
79    let mut prepared_stmts: std::collections::HashMap<u32, crate::wire::listener::PreparedStmt> =
80        std::collections::HashMap::new();
81
82    // After handshake, split the socket so reads and writes are
83    // independent: this is what makes RedWire multiplex (PRD #759
84    // S3) — two concurrent output-stream workers can interleave
85    // their chunks back to the client without contending on the
86    // reader side. All outbound bytes are routed through an
87    // unbounded mpsc; a drain task flushes them to the write half
88    // under a mutex so chunk frames stay byte-atomic on the wire.
89    let (mut reader, writer) = tokio::io::split(stream);
90    let writer = Arc::new(TokioMutex::new(writer));
91    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
92    let writer_drain = Arc::clone(&writer);
93    tokio::spawn(async move {
94        while let Some(bytes) = out_rx.recv().await {
95            let mut w = writer_drain.lock().await;
96            if w.write_all(&bytes).await.is_err() {
97                return;
98            }
99        }
100    });
101
102    // Per-connection output-stream registry (issue #762). Tracks
103    // active stream workers so a `StreamCancel` for one stream_id
104    // does not disturb the rest of the connection.
105    let stream_registry = Arc::new(super::output_stream::StreamRegistry::new());
106
107    // Per-connection input-stream registry (issue #764 / S5). Input
108    // streams are driven inline from this reader loop — each
109    // `StreamChunk` commits synchronously — so the registry is a plain
110    // owned map rather than the `Arc<Mutex<…>>` the spawned output
111    // workers share. Output and input streams are keyed by `stream_id`
112    // in separate registries, so the two multiplex on one connection
113    // without colliding (AC #2).
114    let mut input_registry = super::input_stream::InputStreamRegistry::new();
115
116    // In-flight live queue-wait tasks (issue #920). Unlike the
117    // output-stream workers — which notice a closed connection the
118    // instant they try to push and self-terminate — a queue-wait task
119    // parks on the registry's async wake head and would otherwise linger
120    // until its `wait_ms` deadline after the client disconnects, holding
121    // a registry slot reference and a tokio worker the whole time. Owning
122    // the tasks in a `JoinSet` scoped to this connection fixes that: when
123    // the frame loop returns (Bye / EOF / I/O error), the set is dropped
124    // and every still-parked wait is aborted, dropping its waiter and so
125    // releasing the slot reference promptly (AC #1, AC #3). The
126    // registry's own `cancel_all` still drives the server-shutdown path
127    // (AC #2/#4) independently of this connection-scoped abort.
128    let mut queue_wait_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
129
130    loop {
131        // Reap finished wait tasks so the set does not accumulate joined
132        // handles over a long-lived connection. Non-blocking — only
133        // already-complete tasks are drained.
134        while queue_wait_tasks.try_join_next().is_some() {}
135
136        let frame = match read_frame_async(&mut reader).await {
137            Ok(frame) => frame,
138            Err(reddb_wire::redwire::RedWireIoError::Io(err))
139                if err.kind() == io::ErrorKind::UnexpectedEof =>
140            {
141                return Ok(())
142            }
143            Err(err) => return Err(redwire_io_err(err)),
144        };
145
146        // Catalog-driven direction gate: server-only kinds (PreparedOk,
147        // AuthOk/Fail, BulkOk, …) must never arrive *from* a client.
148        // The catalog (`MessageKind::direction`) is the single source
149        // of truth — see `frame.rs::catalog_tests::direction_matrix_is_pinned`.
150        if frame.kind.direction() == MessageDirection::ServerToClient {
151            let err_frame = build_error_frame_lossy(
152                frame.correlation_id,
153                &format!("redwire: {:?} is server-only", frame.kind),
154            );
155            queue_send(&out_tx, encode_frame(&err_frame))?;
156            continue;
157        }
158
159        match frame.kind {
160            MessageKind::Bye => {
161                let bye = encode_frame(&reply_frame_or_io_error(
162                    frame.correlation_id,
163                    MessageKind::Bye,
164                    vec![],
165                )?);
166                let _ = out_tx.send(bye);
167                return Ok(());
168            }
169            MessageKind::Ping => {
170                let pong = encode_frame(&reply_frame_or_io_error(
171                    frame.correlation_id,
172                    MessageKind::Pong,
173                    vec![],
174                )?);
175                queue_send(&out_tx, pong)?;
176            }
177            MessageKind::Query => {
178                let response = run_query(&runtime, &frame);
179                queue_send(&out_tx, encode_frame(&response))?;
180            }
181            MessageKind::QueryWithParams => {
182                let response = run_query_with_params(&runtime, &frame);
183                queue_send(&out_tx, encode_frame(&response))?;
184            }
185            // BulkInsert handles both single-row and bulk shapes off
186            // the same frame kind: payload `payload` = single,
187            // payload `payloads` = array.
188            MessageKind::BulkInsert => {
189                let response = run_insert_dispatch(&runtime, &frame);
190                queue_send(&out_tx, encode_frame(&response))?;
191            }
192            MessageKind::BulkInsertBinary => {
193                let raw =
194                    crate::wire::listener::handle_bulk_insert_binary(&runtime, &frame.payload);
195                queue_send(
196                    &out_tx,
197                    encode_frame(&rewrap_length_prefixed_handler_response(
198                        &raw,
199                        frame.correlation_id,
200                    )),
201                )?;
202            }
203            MessageKind::BulkInsertPrevalidated => {
204                let raw = crate::wire::listener::handle_bulk_insert_binary_prevalidated(
205                    &runtime,
206                    &frame.payload,
207                );
208                queue_send(
209                    &out_tx,
210                    encode_frame(&rewrap_length_prefixed_handler_response(
211                        &raw,
212                        frame.correlation_id,
213                    )),
214                )?;
215            }
216            MessageKind::QueryBinary => {
217                let raw = crate::wire::listener::handle_query_binary(&runtime, &frame.payload);
218                queue_send(
219                    &out_tx,
220                    encode_frame(&rewrap_length_prefixed_handler_response(
221                        &raw,
222                        frame.correlation_id,
223                    )),
224                )?;
225            }
226            // Streaming bulk insert (PG COPY equivalent).
227            MessageKind::BulkStreamStart => {
228                let raw =
229                    crate::wire::listener::handle_stream_start(&frame.payload, &mut stream_session);
230                queue_send(
231                    &out_tx,
232                    encode_frame(&rewrap_length_prefixed_handler_response(
233                        &raw,
234                        frame.correlation_id,
235                    )),
236                )?;
237            }
238            MessageKind::BulkStreamRows => {
239                let raw = crate::wire::listener::handle_stream_rows(
240                    &runtime,
241                    &frame.payload,
242                    &mut stream_session,
243                );
244                if !raw.is_empty() {
245                    queue_send(
246                        &out_tx,
247                        encode_frame(&rewrap_length_prefixed_handler_response(
248                            &raw,
249                            frame.correlation_id,
250                        )),
251                    )?;
252                }
253            }
254            MessageKind::BulkStreamCommit => {
255                let raw =
256                    crate::wire::listener::handle_stream_commit(&runtime, &mut stream_session);
257                queue_send(
258                    &out_tx,
259                    encode_frame(&rewrap_length_prefixed_handler_response(
260                        &raw,
261                        frame.correlation_id,
262                    )),
263                )?;
264            }
265            MessageKind::Prepare => {
266                let raw = crate::wire::listener::handle_prepare(
267                    &runtime,
268                    &frame.payload,
269                    &mut prepared_stmts,
270                );
271                queue_send(
272                    &out_tx,
273                    encode_frame(&rewrap_length_prefixed_handler_response(
274                        &raw,
275                        frame.correlation_id,
276                    )),
277                )?;
278            }
279            MessageKind::ExecutePrepared => {
280                let raw = crate::wire::listener::handle_execute_prepared(
281                    &runtime,
282                    &frame.payload,
283                    &prepared_stmts,
284                );
285                queue_send(
286                    &out_tx,
287                    encode_frame(&rewrap_length_prefixed_handler_response(
288                        &raw,
289                        frame.correlation_id,
290                    )),
291                )?;
292            }
293            MessageKind::Get => {
294                let response = run_get(&runtime, &frame);
295                queue_send(&out_tx, encode_frame(&response))?;
296            }
297            MessageKind::Delete => {
298                let response = run_delete(&runtime, &frame);
299                queue_send(&out_tx, encode_frame(&response))?;
300            }
301            // Output-stream lifecycle (issue #762 / PRD #759 S3).
302            //
303            // OpenStream: parse payload, register the stream_id with
304            // the per-connection registry, and spawn a worker that
305            // emits OpenAck → StreamChunk* → StreamEnd through the
306            // shared outbound channel. The dispatch loop returns to
307            // reading immediately so concurrent streams interleave
308            // on the wire (AC #2).
309            MessageKind::OpenStream => {
310                use super::output_stream as os;
311                let frame_id = frame.correlation_id;
312                let sid = frame.stream_id;
313
314                // Input-stream open (issue #764 / S5). Distinguished by
315                // `direction: "in"` in the payload; the output path
316                // below (the default) keeps owning `sql`-bearing opens.
317                // Input streams commit chunks inline in this loop, so
318                // they are registered in the owned `input_registry`
319                // rather than spawning a worker.
320                if super::input_stream::open_stream_is_input(&frame.payload) {
321                    use super::input_stream as is;
322                    let req = match is::parse_open_input(&frame.payload) {
323                        Ok(r) => r,
324                        Err(e) => {
325                            let err = is::build_input_stream_error_frame(
326                                frame_id,
327                                sid,
328                                e.code(),
329                                e.message(),
330                                0,
331                                0,
332                            )?;
333                            queue_send(&out_tx, encode_frame(&err))?;
334                            continue;
335                        }
336                    };
337                    let in_tx = runtime.connection_in_transaction(0);
338                    let config = crate::server::output_stream::StreamConfig::load(&runtime);
339                    let snapshot_lsn = runtime.cdc_current_lsn();
340                    let clock = crate::server::output_stream::SystemClock;
341                    let lease = match is::open_input_lease(config, snapshot_lsn, in_tx, &clock) {
342                        Ok(l) => l,
343                        Err(e) => {
344                            let err = is::build_input_stream_error_frame(
345                                frame_id,
346                                sid,
347                                e.code(),
348                                e.message(),
349                                0,
350                                snapshot_lsn,
351                            )?;
352                            queue_send(&out_tx, encode_frame(&err))?;
353                            continue;
354                        }
355                    };
356                    let lease_id = lease.id;
357                    let lease_snapshot = lease.snapshot_lsn;
358                    let state = is::InputStreamState::new(lease, req.target, req.columns);
359                    if let Err(e) = input_registry.register(sid, state) {
360                        let err = is::build_input_stream_error_frame(
361                            frame_id,
362                            sid,
363                            e.code(),
364                            e.message(),
365                            0,
366                            snapshot_lsn,
367                        )?;
368                        queue_send(&out_tx, encode_frame(&err))?;
369                        continue;
370                    }
371                    let ack =
372                        os::build_open_ack_frame(frame_id, sid, lease_id, lease_snapshot, false)
373                            .map_err(|e| io::Error::other(format!("build OpenAck: {e}")))?;
374                    queue_send(&out_tx, encode_frame(&ack))?;
375                    continue;
376                }
377
378                let req = match os::parse_open_stream(&frame.payload) {
379                    Ok(r) => r,
380                    Err(e) => {
381                        let err =
382                            os::build_stream_error_frame(frame_id, sid, e.code(), e.message())?;
383                        queue_send(&out_tx, encode_frame(&err))?;
384                        continue;
385                    }
386                };
387                let cancel_rx = match stream_registry.register(sid).await {
388                    Ok(rx) => rx,
389                    Err(e) => {
390                        let err =
391                            os::build_stream_error_frame(frame_id, sid, e.code(), e.message())?;
392                        queue_send(&out_tx, encode_frame(&err))?;
393                        continue;
394                    }
395                };
396                let runtime_ref = Arc::clone(&runtime);
397                let registry_ref = Arc::clone(&stream_registry);
398                let send = os::FrameTx::new(out_tx.clone());
399                // RedWire today binds every connection to the
400                // default tenant id (0); transactions are managed
401                // per-connection via the task-local context that
402                // HTTP also relies on. The S3 acceptance gate
403                // mirrors S1's HTTP refusal.
404                let in_tx = runtime.connection_in_transaction(0);
405                tokio::spawn(async move {
406                    os::run_output_stream(runtime_ref, frame_id, sid, req, in_tx, cancel_rx, send)
407                        .await;
408                    registry_ref.unregister(sid).await;
409                });
410            }
411            // Live queue wait (issue #917 / PRD #915). Parse the open
412            // request, then spawn a task that awaits the runtime's async
413            // wait edge (parks on the registry's async wake head — no
414            // blocking OS thread) and pushes a `QueueEventPush` the
415            // instant a message becomes deliverable. The dispatch loop
416            // returns to reading immediately so the wait multiplexes
417            // with other frames on the connection.
418            MessageKind::QueueWaitOpen => {
419                use super::queue_wait as qw;
420                let frame_id = frame.correlation_id;
421                let sid = frame.stream_id;
422                let req = match qw::parse_queue_wait_open(&frame.payload) {
423                    Ok(r) => r,
424                    Err(e) => {
425                        let err =
426                            qw::build_queue_wait_error_frame(frame_id, sid, e.code(), e.message())
427                                .map_err(|e| {
428                                    io::Error::other(format!("build queue-wait error: {e}"))
429                                })?;
430                        queue_send(&out_tx, encode_frame(&err))?;
431                        continue;
432                    }
433                };
434                // Server max-wait cap (issue #919, AC #3). Reject an
435                // over-cap budget with an explicit error *before*
436                // spawning the wait task — no waiter is registered and
437                // the budget is never silently shortened.
438                if let Err(msg) = runtime.redwire_queue_wait_cap_check(req.wait_ms) {
439                    let err = qw::build_queue_wait_error_frame(
440                        frame_id,
441                        sid,
442                        qw::WAIT_EXCEEDS_CAP_CODE,
443                        &msg,
444                    )
445                    .map_err(|e| io::Error::other(format!("build queue-wait cap error: {e}")))?;
446                    queue_send(&out_tx, encode_frame(&err))?;
447                    continue;
448                }
449                let runtime_ref = Arc::clone(&runtime);
450                let out = out_tx.clone();
451                let queue_name = req.queue.clone();
452                let wait_ms = req.wait_ms;
453                let auth_identity = Some((session.username.clone(), session.role));
454                let tenant = session.tenant.clone();
455                // Owned by the connection-scoped `JoinSet` so a client
456                // disconnect (frame loop return) aborts a still-parked
457                // wait and releases its registry slot promptly (#920).
458                queue_wait_tasks.spawn(async move {
459                    use crate::runtime::RedwireWaitOutcome;
460                    match runtime_ref
461                        .redwire_queue_wait_json(
462                            &req.queue,
463                            req.group.as_deref(),
464                            &req.consumer,
465                            req.count,
466                            req.wait_ms,
467                            auth_identity,
468                            tenant,
469                        )
470                        .await
471                    {
472                        // Happy path: push each delivered message.
473                        Ok(RedwireWaitOutcome::Delivered(messages)) => {
474                            for message in messages {
475                                match qw::build_event_push_frame(frame_id, sid, &message) {
476                                    Ok(push) => {
477                                        if queue_send(&out, encode_frame(&push)).is_err() {
478                                            return;
479                                        }
480                                    }
481                                    Err(_) => return,
482                                }
483                            }
484                        }
485                        // Deadline elapsed with nothing deliverable: a
486                        // distinct timeout frame, not an empty push and
487                        // not an error (AC #1 / AC #2).
488                        Ok(RedwireWaitOutcome::TimedOut) => {
489                            if let Ok(t) = qw::build_queue_wait_timeout_frame(
490                                frame_id,
491                                sid,
492                                &queue_name,
493                                wait_ms,
494                            ) {
495                                let _ = queue_send(&out, encode_frame(&t));
496                            }
497                        }
498                        // Server-side cancellation: a StreamError with
499                        // the distinct cancellation code so the client
500                        // never confuses it with a timeout (AC #2).
501                        Ok(RedwireWaitOutcome::Cancelled) => {
502                            if let Ok(ef) = qw::build_queue_wait_error_frame(
503                                frame_id,
504                                sid,
505                                qw::WAIT_CANCELLED_CODE,
506                                "queue wait cancelled by server",
507                            ) {
508                                let _ = queue_send(&out, encode_frame(&ef));
509                            }
510                        }
511                        // A genuine runtime failure. Server-shutdown
512                        // cancellation is surfaced as the distinct
513                        // `RedwireWaitOutcome::Cancelled` arm above, so an
514                        // `Err` here is never a cancellation (#920 AC #2).
515                        Err(err) => {
516                            if let Ok(ef) = qw::build_queue_wait_error_frame(
517                                frame_id,
518                                sid,
519                                qw::WAIT_FAILED_CODE,
520                                &err.to_string(),
521                            ) {
522                                let _ = queue_send(&out, encode_frame(&ef));
523                            }
524                        }
525                    }
526                });
527            }
528            // Input-stream chunk (issue #764 / S5). A `StreamChunk`
529            // from the client carries a chunk of rows for an open
530            // input stream. Each chunk commits synchronously and
531            // atomically; success is silent (await the next chunk), a
532            // `terminal: true` chunk closes the stream with a
533            // `StreamEnd`, and a commit failure emits one `StreamError`
534            // (carrying `recoverable_rid`) after which no further
535            // frames are produced for this `stream_id` (AC #3).
536            MessageKind::StreamChunk => {
537                use super::input_stream as is;
538                use crate::server::output_stream::{Clock, SystemClock};
539                let frame_id = frame.correlation_id;
540                let sid = frame.stream_id;
541                if !input_registry.contains(sid) {
542                    // No input stream for this id — protocol violation,
543                    // surfaced as StreamError rather than a drop.
544                    let err = is::build_input_stream_error_frame(
545                        frame_id,
546                        sid,
547                        "unknown_stream",
548                        "no active input stream for this stream_id",
549                        0,
550                        0,
551                    )?;
552                    queue_send(&out_tx, encode_frame(&err))?;
553                    continue;
554                }
555                let chunk = match is::parse_input_chunk(&frame.payload) {
556                    Ok(c) => c,
557                    Err(e) => {
558                        let state = input_registry
559                            .remove(sid)
560                            .expect("stream presence checked above");
561                        let err = is::build_input_stream_error_frame(
562                            frame_id,
563                            sid,
564                            e.code(),
565                            e.message(),
566                            state.chunk_count,
567                            state.committed_rid,
568                        )?;
569                        queue_send(&out_tx, encode_frame(&err))?;
570                        continue;
571                    }
572                };
573                let commit_result = {
574                    let state = input_registry
575                        .get_mut(sid)
576                        .expect("stream presence checked above");
577                    if state.lease.snapshot_expired(SystemClock.now_ms()) {
578                        Err((
579                            "snapshot_expired".to_string(),
580                            "stream snapshot pin TTL elapsed".to_string(),
581                        ))
582                    } else {
583                        state.commit_chunk(&runtime, &chunk.rows)
584                    }
585                };
586                match commit_result {
587                    Err((code, message)) => {
588                        let state = input_registry
589                            .remove(sid)
590                            .expect("stream presence checked above");
591                        let err = is::build_input_stream_error_frame(
592                            frame_id,
593                            sid,
594                            &code,
595                            &message,
596                            state.chunk_count,
597                            state.committed_rid,
598                        )?;
599                        queue_send(&out_tx, encode_frame(&err))?;
600                    }
601                    Ok(()) => {
602                        if chunk.terminal {
603                            let state = input_registry
604                                .remove(sid)
605                                .expect("stream presence checked above");
606                            let end = is::build_input_stream_end_frame(
607                                frame_id,
608                                sid,
609                                state.row_count,
610                                state.chunk_count,
611                                state.committed_rid,
612                                state.snapshot_lsn,
613                                false,
614                            )?;
615                            queue_send(&out_tx, encode_frame(&end))?;
616                        }
617                    }
618                }
619            }
620            MessageKind::StreamCancel => {
621                use super::input_stream as is;
622                use super::output_stream as os;
623                let sid = frame.stream_id;
624                if stream_registry.cancel(sid).await {
625                    // Output stream cancelled — its worker emits the
626                    // terminal StreamEnd(cancelled=true) itself.
627                } else if let Some(state) = input_registry.remove(sid) {
628                    // AC #4 — input-stream cancel: the in-flight (not
629                    // yet committed) chunk is discarded by dropping the
630                    // state; prior per-chunk commits stay durable. Emit
631                    // a terminal StreamEnd with cancelled=true so the
632                    // client can drop its bookkeeping.
633                    let end = is::build_input_stream_end_frame(
634                        frame.correlation_id,
635                        sid,
636                        state.row_count,
637                        state.chunk_count,
638                        state.committed_rid,
639                        state.snapshot_lsn,
640                        true,
641                    )?;
642                    queue_send(&out_tx, encode_frame(&end))?;
643                } else {
644                    // AC #6: protocol violation surfaces as a
645                    // StreamError envelope, not a connection drop.
646                    let err = os::build_stream_error_frame(
647                        frame.correlation_id,
648                        sid,
649                        "unknown_stream",
650                        "no active stream for this stream_id",
651                    )?;
652                    queue_send(&out_tx, encode_frame(&err))?;
653                }
654            }
655            other => {
656                let err_frame = build_error_frame_lossy(
657                    frame.correlation_id,
658                    &format!("redwire: cannot dispatch {other:?} yet"),
659                );
660                queue_send(&out_tx, encode_frame(&err_frame))?;
661            }
662        }
663    }
664}
665
666#[inline]
667fn queue_send(out_tx: &mpsc::UnboundedSender<Vec<u8>>, bytes: Vec<u8>) -> io::Result<()> {
668    out_tx
669        .send(bytes)
670        .map_err(|_| io::Error::other("redwire: write channel closed"))
671}
672
673/// Run the handshake. Returns `Ok(None)` when the client disconnected
674/// or the auth was refused (the failure frame is already on the wire).
675async fn perform_handshake<S>(
676    stream: &mut S,
677    runtime: &RedDBRuntime,
678    auth_store: Option<&AuthStore>,
679    oauth: Option<&crate::auth::oauth::OAuthValidator>,
680) -> io::Result<Option<AuthedSession>>
681where
682    S: AsyncRead + AsyncWrite + Unpin + Send,
683{
684    // Step 1: read minor version byte.
685    let mut minor_buf = [0u8; 1];
686    stream.read_exact(&mut minor_buf).await?;
687    let minor = minor_buf[0];
688    if validate_minor_version(minor).is_err() {
689        // Future client speaking a version we don't know — refuse
690        // immediately. We do not send a frame because the client
691        // hasn't agreed on the framing version yet.
692        return Ok(None);
693    }
694
695    // Step 2: read the Hello frame.
696    let hello = read_frame(stream).await?;
697    if hello.kind != MessageKind::Hello {
698        let fail = encode_frame(&reply_frame_or_io_error(
699            hello.correlation_id,
700            MessageKind::AuthFail,
701            build_auth_fail_payload("first frame after magic must be Hello"),
702        )?);
703        let _ = stream.write_all(&fail).await;
704        return Ok(None);
705    }
706    let hello_msg = match Hello::from_payload(&hello.payload) {
707        Ok(h) => h,
708        Err(e) => {
709            let fail = encode_frame(&reply_frame_or_io_error(
710                hello.correlation_id,
711                MessageKind::AuthFail,
712                build_auth_fail_payload(&e),
713            )?);
714            let _ = stream.write_all(&fail).await;
715            return Ok(None);
716        }
717    };
718
719    let Some(chosen_version) = choose_hello_minor_version(&hello_msg.versions) else {
720        let fail = encode_frame(&reply_frame_or_io_error(
721            hello.correlation_id,
722            MessageKind::AuthFail,
723            build_auth_fail_payload("no overlapping protocol version"),
724        )?);
725        let _ = stream.write_all(&fail).await;
726        return Ok(None);
727    };
728
729    let server_anon_ok = auth_store.map(|s| !s.is_enabled()).unwrap_or(true);
730    let chosen = match pick_auth_method(&hello_msg.auth_methods, server_anon_ok) {
731        Some(m) => m,
732        None => {
733            let fail = encode_frame(&reply_frame_or_io_error(
734                hello.correlation_id,
735                MessageKind::AuthFail,
736                build_auth_fail_payload("no overlapping auth method"),
737            )?);
738            let _ = stream.write_all(&fail).await;
739            return Ok(None);
740        }
741    };
742
743    // Step 3: HelloAck.
744    //
745    // HelloAck is sent before any AuthResponse arrives, so the
746    // caller is unauthenticated at this point. The TopologyAdvertiser
747    // collapses anonymous to primary-only per ADR 0008 §3 — that's
748    // the correct payload for the bootstrap path. Authenticated
749    // principals get the full replica list via the gRPC `Topology`
750    // RPC after the connection is established.
751    let server_features = FEATURE_PARAMS;
752    let topology = build_topology_for_hello_ack(runtime);
753    let ack_frame = build_hello_ack_frame(
754        hello.correlation_id,
755        chosen_version,
756        chosen,
757        server_features,
758        topology.as_ref(),
759    )
760    .map_err(|e| io::Error::other(format!("build HelloAck: {e}")))?;
761    let ack = encode_frame(&ack_frame);
762    stream.write_all(&ack).await?;
763
764    // SCRAM is a 3-RTT challenge/response exchange. Branch off to
765    // its own state machine before the 1-RTT bearer/anonymous
766    // path runs.
767    if chosen == "scram-sha-256" {
768        return perform_scram_handshake(stream, auth_store, hello.correlation_id, server_features)
769            .await;
770    }
771
772    // Step 4: AuthResponse (no challenge for the 1-RTT methods —
773    // bearer/anonymous send their proof in the first AuthResponse).
774    let resp = read_frame(stream).await?;
775    let auth_payload = match expect_auth_response_payload(resp.kind, &resp.payload, "AuthResponse")
776    {
777        Ok(payload) => payload,
778        Err(err) => {
779            let fail = encode_frame(&reply_frame_or_io_error(
780                resp.correlation_id,
781                MessageKind::AuthFail,
782                build_auth_fail_payload(&err.to_string()),
783            )?);
784            let _ = stream.write_all(&fail).await;
785            return Ok(None);
786        }
787    };
788
789    // OAuth-JWT branch. The `jwt` field carries either a browser access
790    // JWT (the hybrid-token model, issue #936) or a federated IdP token
791    // validated by the configured `OAuthValidator`. The browser access
792    // token is tried *first* and independently of `oauth` being wired, so
793    // a deployment that runs the browser credential layer without any
794    // external OAuth IdP still authenticates. mTLS stays native-only
795    // (ADR 0036) — the browser presents this access JWT and nothing else.
796    if chosen == "oauth-jwt" {
797        let raw = match parse_auth_response_oauth_jwt(auth_payload) {
798            Ok(raw) if !raw.is_empty() => raw,
799            _ => {
800                let fail = encode_frame(&reply_frame_or_io_error(
801                    resp.correlation_id,
802                    MessageKind::AuthFail,
803                    build_auth_fail_payload("oauth-jwt: AuthResponse missing 'jwt' string"),
804                )?);
805                let _ = stream.write_all(&fail).await;
806                return Ok(None);
807            }
808        };
809
810        // 1. Browser hybrid-token access JWT (issue #936). A *valid*
811        //    access token (correct issuer/audience/signature, `typ:
812        //    access`, unexpired) authenticates the session directly.
813        //    Anything else (expired, wrong type, or simply not one of our
814        //    tokens — e.g. a foreign IdP RS256 token) falls through to the
815        //    OAuth validator below; the net effect is "valid browser
816        //    token accepted, expired/invalid rejected" (AC #2). The stream
817        //    lease (ADR 0029) then decouples this token's expiry from any
818        //    stream the session opens, so a later refresh never tears down
819        //    in-flight work (AC #3).
820        if let Some(authority) = runtime.browser_token_authority() {
821            let now = std::time::SystemTime::now()
822                .duration_since(std::time::UNIX_EPOCH)
823                .map(|d| d.as_secs() as i64)
824                .unwrap_or(0);
825            if let Ok(identity) = authority.validate_access(&raw, now) {
826                let session_id = super::auth::new_session_id_for_scram();
827                let ok = encode_frame(&reply_frame_or_io_error(
828                    resp.correlation_id,
829                    MessageKind::AuthOk,
830                    build_auth_ok(
831                        &session_id,
832                        &identity.username,
833                        identity.role,
834                        server_features,
835                    ),
836                )?);
837                stream.write_all(&ok).await?;
838                return Ok(Some(AuthedSession {
839                    username: identity.username,
840                    role: identity.role,
841                    tenant: identity.tenant,
842                    session_id,
843                }));
844            }
845        }
846
847        // 2. Federated OAuth-JWT (RS256/ES256 against a configured IdP).
848        let validator = match oauth {
849            Some(v) => v,
850            None => {
851                let fail = encode_frame(&reply_frame_or_io_error(
852                    resp.correlation_id,
853                    MessageKind::AuthFail,
854                    build_auth_fail_payload(
855                        "oauth-jwt: token rejected (no browser-token authority or OAuth validator accepted it)",
856                    ),
857                )?);
858                let _ = stream.write_all(&fail).await;
859                return Ok(None);
860            }
861        };
862        match super::auth::validate_oauth_jwt_full(validator, &raw) {
863            Ok((tenant, username, role)) => {
864                let session_id = super::auth::new_session_id_for_scram();
865                let ok = encode_frame(&reply_frame_or_io_error(
866                    resp.correlation_id,
867                    MessageKind::AuthOk,
868                    build_auth_ok(&session_id, &username, role, server_features),
869                )?);
870                stream.write_all(&ok).await?;
871                return Ok(Some(AuthedSession {
872                    username,
873                    role,
874                    tenant,
875                    session_id,
876                }));
877            }
878            Err(reason) => {
879                let fail = encode_frame(&reply_frame_or_io_error(
880                    resp.correlation_id,
881                    MessageKind::AuthFail,
882                    build_auth_fail_payload(&format!("oauth-jwt: {reason}")),
883                )?);
884                let _ = stream.write_all(&fail).await;
885                return Ok(None);
886            }
887        }
888    }
889
890    match validate_auth_response(chosen, auth_payload, auth_store) {
891        AuthOutcome::Authenticated {
892            username,
893            role,
894            tenant,
895            session_id,
896        } => {
897            let ok_frame = build_auth_ok_frame_from_payload(
898                resp.correlation_id,
899                build_auth_ok(&session_id, &username, role, server_features),
900            )
901            .map_err(|e| io::Error::other(format!("build AuthOk: {e}")))?;
902            let ok = encode_frame(&ok_frame);
903            stream.write_all(&ok).await?;
904            Ok(Some(AuthedSession {
905                username,
906                role,
907                tenant,
908                session_id,
909            }))
910        }
911        AuthOutcome::Refused(reason) => {
912            let fail = encode_frame(&reply_frame_or_io_error(
913                resp.correlation_id,
914                MessageKind::AuthFail,
915                build_auth_fail_payload(&reason),
916            )?);
917            let _ = stream.write_all(&fail).await;
918            Ok(None)
919        }
920    }
921}
922
923/// 3-RTT SCRAM-SHA-256 server handshake (RFC 5802 + RFC 7677).
924///
925/// ```text
926/// C → S  AuthResponse(client-first-message)         (already received as client-first)
927/// S → C  AuthRequest(server-first-message)
928/// C → S  AuthResponse(client-final-message)
929/// S → C  AuthOk(v=server-signature)
930/// ```
931async fn perform_scram_handshake<S>(
932    stream: &mut S,
933    auth_store: Option<&AuthStore>,
934    initial_correlation: u64,
935    server_features: u32,
936) -> io::Result<Option<AuthedSession>>
937where
938    S: AsyncRead + AsyncWrite + Unpin + Send,
939{
940    let store = match auth_store {
941        Some(s) => s,
942        None => {
943            let fail = encode_frame(&reply_frame_or_io_error(
944                initial_correlation,
945                MessageKind::AuthFail,
946                build_auth_fail_payload("scram-sha-256 requires an AuthStore"),
947            )?);
948            let _ = stream.write_all(&fail).await;
949            return Ok(None);
950        }
951    };
952
953    // 1. Client-first.
954    let cf = read_frame(stream).await?;
955    let cf_payload = match expect_auth_response_payload(
956        cf.kind,
957        &cf.payload,
958        "AuthResponse(client-first-message)",
959    ) {
960        Ok(payload) => payload,
961        Err(err) => {
962            let fail = encode_frame(&reply_frame_or_io_error(
963                cf.correlation_id,
964                MessageKind::AuthFail,
965                build_auth_fail_payload(&err.to_string()),
966            )?);
967            let _ = stream.write_all(&fail).await;
968            return Ok(None);
969        }
970    };
971    let (username, client_nonce, client_first_bare) =
972        match reddb_wire::redwire::handshake::parse_scram_client_first(cf_payload) {
973            Ok(t) => t,
974            Err(e) => {
975                let fail = encode_frame(&reply_frame_or_io_error(
976                    cf.correlation_id,
977                    MessageKind::AuthFail,
978                    build_auth_fail_payload(&format!("scram client-first: {e}")),
979                )?);
980                let _ = stream.write_all(&fail).await;
981                return Ok(None);
982            }
983        };
984
985    // 2. Look up the verifier. The wire handshake doesn't yet learn
986    // a tenant before the SCRAM exchange completes, so we resolve
987    // against the platform tenant. Tenant-scoped users authenticate
988    // through the JWT path (which carries the tenant claim) or a
989    // future explicit `tenant` extension to the AuthRequest payload.
990    // If the user doesn't exist or has no SCRAM verifier, run a
991    // dummy iteration count to keep the timing flat
992    // (no user-enumeration leak).
993    let verifier = store.lookup_scram_verifier_global(&username);
994    let (salt, iter, stored_key, server_key, user_known) = match &verifier {
995        Some(v) => (v.salt.clone(), v.iter, v.stored_key, v.server_key, true),
996        None => (
997            crate::auth::store::random_bytes(16),
998            crate::auth::scram::DEFAULT_ITER,
999            [0u8; 32],
1000            [0u8; 32],
1001            false,
1002        ),
1003    };
1004
1005    // 3. Server-first.
1006    let server_nonce = super::auth::new_server_nonce();
1007    let server_first = reddb_wire::redwire::handshake::build_scram_server_first(
1008        &client_nonce,
1009        &server_nonce,
1010        &salt,
1011        iter,
1012    );
1013    let req = encode_frame(&reply_frame_or_io_error(
1014        cf.correlation_id,
1015        MessageKind::AuthRequest,
1016        server_first.as_bytes().to_vec(),
1017    )?);
1018    stream.write_all(&req).await?;
1019
1020    // 4. Client-final.
1021    let cfinal = read_frame(stream).await?;
1022    let cfinal_payload = match expect_auth_response_payload(
1023        cfinal.kind,
1024        &cfinal.payload,
1025        "AuthResponse(client-final-message)",
1026    ) {
1027        Ok(payload) => payload,
1028        Err(err) => {
1029            let fail = encode_frame(&reply_frame_or_io_error(
1030                cfinal.correlation_id,
1031                MessageKind::AuthFail,
1032                build_auth_fail_payload(&err.to_string()),
1033            )?);
1034            let _ = stream.write_all(&fail).await;
1035            return Ok(None);
1036        }
1037    };
1038    let (combined_nonce, presented_proof, client_final_no_proof) =
1039        match reddb_wire::redwire::handshake::parse_scram_client_final(cfinal_payload) {
1040            Ok(t) => t,
1041            Err(e) => {
1042                let fail = encode_frame(&reply_frame_or_io_error(
1043                    cfinal.correlation_id,
1044                    MessageKind::AuthFail,
1045                    build_auth_fail_payload(&format!("scram client-final: {e}")),
1046                )?);
1047                let _ = stream.write_all(&fail).await;
1048                return Ok(None);
1049            }
1050        };
1051    let expected_combined = format!("{client_nonce}{server_nonce}");
1052    if combined_nonce != expected_combined {
1053        let fail = encode_frame(&reply_frame_or_io_error(
1054            cfinal.correlation_id,
1055            MessageKind::AuthFail,
1056            build_auth_fail_payload("scram nonce mismatch — replay protection failed"),
1057        )?);
1058        let _ = stream.write_all(&fail).await;
1059        return Ok(None);
1060    }
1061
1062    // 5. Verify proof.
1063    let auth_message =
1064        crate::auth::scram::auth_message(&client_first_bare, &server_first, &client_final_no_proof);
1065    let proof_ok = if user_known {
1066        let v = crate::auth::scram::ScramVerifier {
1067            salt: salt.clone(),
1068            iter,
1069            stored_key,
1070            server_key,
1071        };
1072        crate::auth::scram::verify_client_proof(&v, &auth_message, &presented_proof)
1073    } else {
1074        false
1075    };
1076    if !proof_ok {
1077        let fail = encode_frame(&reply_frame_or_io_error(
1078            cfinal.correlation_id,
1079            MessageKind::AuthFail,
1080            build_auth_fail_payload("invalid SCRAM proof"),
1081        )?);
1082        let _ = stream.write_all(&fail).await;
1083        return Ok(None);
1084    }
1085
1086    // 6. AuthOk with server signature.
1087    let user = store
1088        .list_users()
1089        .into_iter()
1090        .find(|u| u.username == username);
1091    let role = user
1092        .as_ref()
1093        .map(|u| u.role)
1094        .unwrap_or(crate::auth::Role::Read);
1095    let server_sig = crate::auth::scram::server_signature(&server_key, &auth_message);
1096    let session_id = super::auth::new_session_id_for_scram();
1097    let ok_payload = super::auth::build_scram_auth_ok(
1098        &session_id,
1099        &username,
1100        role,
1101        server_features,
1102        &server_sig,
1103    );
1104    let ok = encode_frame(&reply_frame_or_io_error(
1105        cfinal.correlation_id,
1106        MessageKind::AuthOk,
1107        ok_payload,
1108    )?);
1109    stream.write_all(&ok).await?;
1110    Ok(Some(AuthedSession {
1111        username,
1112        role,
1113        tenant: user.and_then(|u| u.tenant_id),
1114        session_id,
1115    }))
1116}
1117
1118async fn read_frame<S>(stream: &mut S) -> io::Result<Frame>
1119where
1120    S: AsyncRead + Unpin + Send,
1121{
1122    read_frame_async(stream).await.map_err(redwire_io_err)
1123}
1124
1125fn redwire_io_err(err: reddb_wire::redwire::RedWireIoError) -> io::Error {
1126    match err {
1127        reddb_wire::redwire::RedWireIoError::Io(err) => err,
1128        reddb_wire::redwire::RedWireIoError::Frame(err) => {
1129            io::Error::other(format!("decode frame: {err}"))
1130        }
1131    }
1132}
1133
1134fn run_query(runtime: &RedDBRuntime, frame: &Frame) -> Frame {
1135    let sql = match std::str::from_utf8(&frame.payload) {
1136        Ok(s) => s,
1137        Err(_) => {
1138            return build_error_frame_lossy(
1139                frame.correlation_id,
1140                "Query payload must be UTF-8 SQL",
1141            );
1142        }
1143    };
1144    match runtime.execute_query(sql) {
1145        Ok(result) => {
1146            let payload =
1147                encode_query_result_summary_payload(result.statement_type, result.affected_rows);
1148            build_dispatch_reply_frame(frame.correlation_id, MessageKind::Result, payload)
1149        }
1150        Err(err) => build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1151    }
1152}
1153
1154fn run_query_with_params(runtime: &RedDBRuntime, frame: &Frame) -> Frame {
1155    let request = match decode_query_with_params_request(&frame.payload) {
1156        Ok(decoded) => decoded,
1157        Err(err) => return build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1158    };
1159    let commit_policy = match parse_redwire_commit_policy(request.options.commit_policy.as_deref())
1160    {
1161        Ok(policy) => policy,
1162        Err(err) => return build_error_frame_lossy(frame.correlation_id, &err),
1163    };
1164    let params = request
1165        .params
1166        .into_iter()
1167        .map(param_to_schema_value)
1168        .collect::<Vec<_>>();
1169    match runtime.execute_query_with_params(&request.sql, &params) {
1170        Ok(result) => {
1171            let is_mutation = matches!(result.statement_type, "insert" | "update" | "delete");
1172            if is_mutation {
1173                let post_lsn = runtime.cdc_current_lsn();
1174                if let Err(err) = runtime.enforce_commit_policy_for_request(post_lsn, commit_policy)
1175                {
1176                    return build_error_frame_lossy(frame.correlation_id, &err.to_string());
1177                }
1178            }
1179            let payload =
1180                crate::presentation::query_result_json::runtime_query_json(&result, &None, &None)
1181                    .to_string_compact()
1182                    .into_bytes();
1183            build_dispatch_reply_frame(frame.correlation_id, MessageKind::Result, payload)
1184        }
1185        Err(err) => build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1186    }
1187}
1188
1189fn parse_redwire_commit_policy(
1190    value: Option<&str>,
1191) -> Result<Option<crate::replication::CommitPolicy>, String> {
1192    let Some(value) = value else {
1193        return Ok(None);
1194    };
1195    crate::replication::CommitPolicy::parse_strict(value)
1196        .map(Some)
1197        .ok_or_else(|| format!("invalid commit_policy value '{value}'"))
1198}
1199
1200fn param_to_schema_value(value: RedWireParamValue) -> crate::storage::schema::Value {
1201    use crate::storage::schema::Value;
1202    match value {
1203        RedWireParamValue::Null => Value::Null,
1204        RedWireParamValue::Bool(value) => Value::Boolean(value),
1205        RedWireParamValue::Int(value) => Value::Integer(value),
1206        RedWireParamValue::Float(value) => Value::Float(value),
1207        RedWireParamValue::Text(value) => Value::Text(Arc::from(value.as_str())),
1208        RedWireParamValue::Bytes(value) => Value::Blob(value),
1209        RedWireParamValue::Vector(value) => Value::Vector(value),
1210        RedWireParamValue::Json(value) => Value::Json(value),
1211        RedWireParamValue::Timestamp(value) => Value::Timestamp(value),
1212        RedWireParamValue::Uuid(value) => Value::Uuid(value),
1213    }
1214}
1215
1216/// Insert dispatch — handles single-row, bulk, and the analytics
1217/// batch shape off the same `BulkInsert` (0x04) frame:
1218///   - `{ "collection": "...", "payload": {...} }` → single insert
1219///   - `{ "collection": "...", "payloads": [...] }` → bulk insert
1220///   - `{ "collection": "...", "payloads": [...], "idempotency_key": "...",
1221///       "batch": true? }` → analytics `BatchInsertEndpoint`
1222///     (issue #587) — all-or-nothing commit with
1223///     `AnalyticsSchemaRegistry` validation up front and replay served
1224///     from the process-wide cache shared with the HTTP (#582) and
1225///     gRPC (#585) mirrors. Either an `idempotency_key` OR `batch:
1226///     true` flips the dispatch — the literal idempotency key in the
1227///     frame is the canonical signal in the brief, the boolean lets a
1228///     client opt into the validation semantics without committing to
1229///     a cache window.
1230///
1231/// Mirrors the JSON-RPC `insert` / `bulk_insert` method shapes
1232/// from `rpc_stdio.rs` so both transports agree on the payload.
1233fn run_insert_dispatch(runtime: &RedDBRuntime, frame: &Frame) -> Frame {
1234    let request = match decode_insert_dispatch_payload(&frame.payload) {
1235        Ok(request) => request,
1236        Err(err) => return build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1237    };
1238    let collection = request.collection.as_str();
1239    let payload = request.payload.map(wire_json_to_server_json);
1240    let payloads = request.payloads.map(|rows| {
1241        rows.into_iter()
1242            .map(wire_json_to_server_json)
1243            .collect::<Vec<_>>()
1244    });
1245
1246    // Analytics batch-insert path (issue #587). Either field flips the
1247    // dispatch — the brief carries `idempotency_key` as the canonical
1248    // signal; the optional `batch: true` boolean exists for callers
1249    // that want the validation contract without committing to a
1250    // replay window.
1251    let idempotency_key = request.idempotency_key.as_deref();
1252    if idempotency_key.is_some() || request.batch {
1253        let items = match payloads.as_deref() {
1254            Some(rows) => rows,
1255            None => {
1256                return build_error_frame_lossy(
1257                    frame.correlation_id,
1258                    "BatchInsert: missing 'payloads' array",
1259                )
1260            }
1261        };
1262        let outcome = crate::server::handlers_entity::process_batch_insert(
1263            runtime,
1264            collection,
1265            items,
1266            idempotency_key,
1267        );
1268        // Mirror the HTTP transport's status convention: 2xx → BulkOk,
1269        // everything else → Error frame (the body carries the typed
1270        // code/row_index envelope so the client can decode it without
1271        // an out-of-band header).
1272        let kind = if (200..300).contains(&outcome.status) {
1273            MessageKind::BulkOk
1274        } else {
1275            MessageKind::Error
1276        };
1277        return build_dispatch_reply_frame(frame.correlation_id, kind, outcome.body);
1278    }
1279
1280    if let Some(rows) = payloads.as_ref() {
1281        let mut objects = Vec::with_capacity(rows.len());
1282        for entry in rows {
1283            objects.push(match entry.as_object() {
1284                Some(o) => o,
1285                None => {
1286                    return build_error_frame_lossy(
1287                        frame.correlation_id,
1288                        "Insert: each payload must be a JSON object",
1289                    )
1290                }
1291            });
1292        }
1293
1294        if crate::rpc_stdio::should_bulk_insert_graph(runtime, collection, &objects) {
1295            return match crate::rpc_stdio::bulk_insert_graph(runtime, collection, &objects) {
1296                Ok(body) => {
1297                    let payload = body.to_string_compact().into_bytes();
1298                    build_dispatch_reply_frame(frame.correlation_id, MessageKind::BulkOk, payload)
1299                }
1300                Err(err) => build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1301            };
1302        }
1303
1304        let mut affected: u64 = 0;
1305        let mut ids = Vec::with_capacity(objects.len());
1306        for row in objects {
1307            let sql = crate::rpc_stdio::build_insert_sql(collection, row.iter());
1308            match runtime.execute_query(&sql) {
1309                Ok(qr) => {
1310                    affected += qr.affected_rows;
1311                    if let Some(id) = crate::rpc_stdio::insert_result_to_json(&qr).get("id") {
1312                        ids.push(id.clone());
1313                    }
1314                }
1315                Err(err) => return build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1316            }
1317        }
1318        let payload = encode_bulk_ok_payload_from_json_id_literals(
1319            affected,
1320            ids.iter().map(|id| id.to_string()),
1321        );
1322        return build_dispatch_reply_frame(frame.correlation_id, MessageKind::BulkOk, payload);
1323    }
1324
1325    let row = match payload.as_ref().and_then(|x| x.as_object()) {
1326        Some(o) => o,
1327        None => {
1328            return build_error_frame_lossy(
1329                frame.correlation_id,
1330                "Insert: missing 'payload' object or 'payloads' array",
1331            )
1332        }
1333    };
1334    let sql = crate::rpc_stdio::build_insert_sql(collection, row.iter());
1335    match runtime.execute_query(&sql) {
1336        Ok(qr) => {
1337            let body = crate::rpc_stdio::insert_result_to_json(&qr);
1338            let payload = body.to_string_compact().into_bytes();
1339            build_dispatch_reply_frame(frame.correlation_id, MessageKind::BulkOk, payload)
1340        }
1341        Err(err) => build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1342    }
1343}
1344
1345/// Build the primary-only topology payload embedded in HelloAck
1346/// (issue #167). Threads an anonymous auth context through
1347/// `TopologyAdvertiser::advertise` because the principal is not yet
1348/// known at HelloAck time — ADR 0008 §3 collapses anonymous to a
1349/// primary-only payload, which is exactly the bootstrap shape we
1350/// want here.
1351///
1352/// Returns `None` for non-primary roles or when the engine is not
1353/// running with replication enabled. Old clients that don't
1354/// understand the `topology` JSON key ignore it cleanly (ADR §4),
1355/// so the absent-vs-present distinction is benign.
1356fn build_topology_for_hello_ack(runtime: &RedDBRuntime) -> Option<reddb_wire::topology::Topology> {
1357    use crate::auth::middleware::AuthResult;
1358    use crate::replication::{LagConfig, TopologyAdvertiser};
1359    use reddb_wire::topology::Endpoint;
1360
1361    let db = runtime.db();
1362    let primary_endpoint = Endpoint {
1363        addr: runtime.config_string("red.redwire.advertise_addr", ""),
1364        region: db.options().replication.region.clone(),
1365    };
1366    let (replicas, current_lsn, epoch) = match db.replication.as_ref() {
1367        Some(repl) => (
1368            repl.replica_snapshots(),
1369            repl.wal_buffer.current_lsn(),
1370            repl.topology_epoch(),
1371        ),
1372        None => (Vec::new(), 0u64, 0u64),
1373    };
1374    let lag = LagConfig::from_now();
1375    Some(TopologyAdvertiser::advertise(
1376        &replicas,
1377        &AuthResult::Anonymous,
1378        epoch,
1379        primary_endpoint,
1380        current_lsn,
1381        &lag,
1382    ))
1383}
1384
1385fn reply_frame_or_io_error(
1386    correlation_id: u64,
1387    kind: MessageKind,
1388    payload: Vec<u8>,
1389) -> io::Result<Frame> {
1390    build_reply_frame(correlation_id, kind, payload)
1391        .map_err(|e| io::Error::other(format!("build {kind:?}: {e}")))
1392}
1393
1394fn wire_json_to_server_json(value: impl std::fmt::Display) -> JsonValue {
1395    serde_json::from_str::<JsonValue>(&value.to_string()).unwrap_or(JsonValue::Null)
1396}
1397
1398/// Get payload shape: `{ "collection": "...", "id": "..." }`.
1399/// Bridges to `SELECT * FROM <coll> WHERE _id = '<id>' LIMIT 1`.
1400/// Reply: Result frame with the row, or empty `{}` when not found.
1401fn run_get(runtime: &RedDBRuntime, frame: &Frame) -> Frame {
1402    let request = match decode_get_payload(&frame.payload) {
1403        Ok(request) => request,
1404        Err(err) => return build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1405    };
1406    // Sanitise the id by treating it as a string literal — same
1407    // approach as build_insert_sql for arbitrary input.
1408    let id_lit = crate::rpc_stdio::value_to_sql_literal(&JsonValue::String(request.id));
1409    let sql = format!(
1410        "SELECT * FROM {} WHERE _id = {id_lit} LIMIT 1",
1411        request.collection
1412    );
1413    match runtime.execute_query(&sql) {
1414        Ok(qr) => {
1415            // Preserve the existing Get envelope: presence only.
1416            let payload = encode_get_result_payload(!qr.result.records.is_empty());
1417            build_dispatch_reply_frame(frame.correlation_id, MessageKind::Result, payload)
1418        }
1419        Err(err) => build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1420    }
1421}
1422
1423/// Delete payload shape: `{ "collection": "...", "id": "..." }`.
1424/// Bridges to `DELETE FROM <coll> WHERE _id = '<id>'`.
1425/// Reply: DeleteOk frame with `{ affected }`.
1426fn run_delete(runtime: &RedDBRuntime, frame: &Frame) -> Frame {
1427    let request = match decode_delete_payload(&frame.payload) {
1428        Ok(request) => request,
1429        Err(err) => return build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1430    };
1431    let id_lit = crate::rpc_stdio::value_to_sql_literal(&JsonValue::String(request.id));
1432    let sql = format!("DELETE FROM {} WHERE _id = {id_lit}", request.collection);
1433    match runtime.execute_query(&sql) {
1434        Ok(qr) => {
1435            let payload = encode_delete_ok_payload(qr.affected_rows);
1436            build_dispatch_reply_frame(frame.correlation_id, MessageKind::DeleteOk, payload)
1437        }
1438        Err(err) => build_error_frame_lossy(frame.correlation_id, &err.to_string()),
1439    }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    use super::*;
1445
1446    use crate::runtime::RedDBRuntime;
1447    use std::sync::{Mutex, OnceLock};
1448    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1449
1450    fn env_lock() -> &'static Mutex<()> {
1451        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1452        LOCK.get_or_init(|| Mutex::new(()))
1453    }
1454
1455    struct EnvGuard {
1456        previous: Vec<(&'static str, Option<String>)>,
1457    }
1458
1459    impl EnvGuard {
1460        fn set(vars: &[(&'static str, &'static str)]) -> Self {
1461            let previous = vars
1462                .iter()
1463                .map(|(key, _)| (*key, std::env::var(key).ok()))
1464                .collect();
1465            for (key, value) in vars {
1466                std::env::set_var(key, value);
1467            }
1468            Self { previous }
1469        }
1470    }
1471
1472    impl Drop for EnvGuard {
1473        fn drop(&mut self) {
1474            for (key, value) in self.previous.iter().rev() {
1475                match value {
1476                    Some(value) => std::env::set_var(key, value),
1477                    None => std::env::remove_var(key),
1478                }
1479            }
1480        }
1481    }
1482
1483    fn temp_data_path(name: &str) -> std::path::PathBuf {
1484        let mut path = std::env::temp_dir();
1485        path.push(format!(
1486            "reddb-redwire-{name}-{}-{}.rdb",
1487            std::process::id(),
1488            crate::utils::now_unix_millis()
1489        ));
1490        let _ = std::fs::remove_file(&path);
1491        path
1492    }
1493
1494    fn bulk_insert_frame(correlation_id: u64, payload: Vec<u8>) -> Frame {
1495        reddb_wire::redwire::build_bulk_insert_frame(correlation_id, payload)
1496            .expect("build bulk insert frame")
1497    }
1498
1499    fn create_graph_collection(runtime: &RedDBRuntime, name: &str) {
1500        let db = runtime.db();
1501        db.store()
1502            .create_collection(name)
1503            .expect("create collection");
1504        let now = std::time::SystemTime::now()
1505            .duration_since(std::time::UNIX_EPOCH)
1506            .unwrap_or_default()
1507            .as_millis();
1508        db.save_collection_contract(crate::physical::CollectionContract {
1509            name: name.to_string(),
1510            declared_model: crate::catalog::CollectionModel::Graph,
1511            schema_mode: crate::catalog::SchemaMode::Dynamic,
1512            origin: crate::physical::ContractOrigin::Explicit,
1513            version: 1,
1514            created_at_unix_ms: now,
1515            updated_at_unix_ms: now,
1516            default_ttl_ms: None,
1517            vector_dimension: None,
1518            vector_metric: None,
1519            context_index_fields: Vec::new(),
1520            declared_columns: Vec::new(),
1521            table_def: None,
1522            timestamps_enabled: false,
1523            context_index_enabled: false,
1524            metrics_raw_retention_ms: None,
1525            metrics_rollup_policies: Vec::new(),
1526            metrics_tenant_identity: None,
1527            metrics_namespace: None,
1528            append_only: false,
1529            subscriptions: Vec::new(),
1530            analytics_config: Vec::new(),
1531            session_key: None,
1532            session_gap_ms: None,
1533            retention_duration_ms: None,
1534            analytical_storage: None,
1535
1536            ai_policy: None,
1537        })
1538        .expect("save graph contract");
1539    }
1540
1541    #[test]
1542    fn magic_byte_is_0xfe() {
1543        assert_eq!(REDWIRE_MAGIC, 0xFE);
1544    }
1545
1546    #[test]
1547    fn redwire_bulk_insert_graph_rows_returns_ids() {
1548        let runtime = RedDBRuntime::in_memory().expect("runtime");
1549        create_graph_collection(&runtime, "network");
1550
1551        let nodes = bulk_insert_frame(
1552            7,
1553            br#"{"collection":"network","payloads":[{"label":"Host","name":"app"},{"label":"Host","name":"db"}]}"#.to_vec(),
1554        );
1555        let nodes_reply = run_insert_dispatch(&runtime, &nodes);
1556        assert_eq!(nodes_reply.kind, MessageKind::BulkOk);
1557        let node_body: JsonValue =
1558            serde_json::from_slice(&nodes_reply.payload).expect("nodes json");
1559        assert_eq!(
1560            node_body.get("affected").and_then(JsonValue::as_u64),
1561            Some(2)
1562        );
1563        let ids = node_body
1564            .get("ids")
1565            .and_then(JsonValue::as_array)
1566            .expect("node ids");
1567        assert_eq!(ids.len(), 2);
1568
1569        let from = ids[0].as_u64().expect("from id");
1570        let to = ids[1].as_u64().expect("to id");
1571        let edges = bulk_insert_frame(
1572            8,
1573            format!(
1574                r#"{{"collection":"network","payloads":[{{"label":"connects","from":{from},"to":{to},"role":"primary"}}]}}"#
1575            )
1576            .into_bytes(),
1577        );
1578        let edges_reply = run_insert_dispatch(&runtime, &edges);
1579        assert_eq!(edges_reply.kind, MessageKind::BulkOk);
1580        let edge_body: JsonValue =
1581            serde_json::from_slice(&edges_reply.payload).expect("edges json");
1582        assert_eq!(
1583            edge_body.get("affected").and_then(JsonValue::as_u64),
1584            Some(1)
1585        );
1586        assert_eq!(
1587            edge_body
1588                .get("ids")
1589                .and_then(JsonValue::as_array)
1590                .map(|ids| ids.len()),
1591            Some(1)
1592        );
1593    }
1594
1595    #[test]
1596    fn redwire_query_with_params_preserves_json_columns() {
1597        let runtime = RedDBRuntime::in_memory().expect("runtime");
1598        runtime
1599            .execute_query("KV PUT proj.a.b.c.d = 12")
1600            .expect("put nested number");
1601        runtime
1602            .execute_query("KV PUT proj.a.b.e = 'x'")
1603            .expect("put nested string");
1604
1605        let payload =
1606            reddb_wire::query_with_params::encode_query_with_params("LIST KV proj AS JSON", &[])
1607                .expect("encode query with params");
1608        let frame = reddb_wire::redwire::build_query_with_params_frame(99, payload)
1609            .expect("query-with-params frame");
1610        let reply = run_query_with_params(&runtime, &frame);
1611
1612        assert_eq!(
1613            reply.kind,
1614            MessageKind::Result,
1615            "body={}",
1616            String::from_utf8_lossy(&reply.payload)
1617        );
1618        let body: JsonValue = serde_json::from_slice(&reply.payload).expect("result json");
1619        let value = body
1620            .get("result")
1621            .and_then(|result| result.get("records"))
1622            .and_then(JsonValue::as_array)
1623            .and_then(|records| records.first())
1624            .and_then(|record| record.get("values"))
1625            .and_then(|values| values.get("value"))
1626            .expect("json value column");
1627
1628        assert_eq!(
1629            value
1630                .get("a")
1631                .and_then(|a| a.get("b"))
1632                .and_then(|b| b.get("c"))
1633                .and_then(|c| c.get("d"))
1634                .and_then(JsonValue::as_f64),
1635            Some(12.0)
1636        );
1637        assert_eq!(
1638            value
1639                .get("a")
1640                .and_then(|a| a.get("b"))
1641                .and_then(|b| b.get("e"))
1642                .and_then(JsonValue::as_str),
1643            Some("x")
1644        );
1645    }
1646
1647    #[test]
1648    fn redwire_query_with_params_request_policy_strengthens_to_ack_n() {
1649        let _env_lock = env_lock().lock().expect("env lock");
1650        let _env = EnvGuard::set(&[
1651            ("RED_PRIMARY_COMMIT_POLICY", "local"),
1652            ("RED_REPLICATION_ACK_TIMEOUT_MS", "20"),
1653            ("RED_COMMIT_FAIL_ON_TIMEOUT", "true"),
1654        ]);
1655        let data_path = temp_data_path("request-ack-n");
1656        let runtime = RedDBRuntime::with_options(
1657            crate::api::RedDBOptions::persistent(&data_path)
1658                .with_replication(crate::replication::ReplicationConfig::primary()),
1659        )
1660        .expect("runtime");
1661
1662        let payload = reddb_wire::query_with_params::encode_query_with_params_request(
1663            "INSERT INTO redwire_request_ack (id, name) VALUES (1, 'alpha')",
1664            &[],
1665            &reddb_wire::query_with_params::QueryWithParamsOptions {
1666                commit_policy: Some("ack_n=1".to_string()),
1667            },
1668        )
1669        .expect("encode query with request policy");
1670        let frame = reddb_wire::redwire::build_query_with_params_frame(100, payload)
1671            .expect("query-with-params frame");
1672        let reply = run_query_with_params(&runtime, &frame);
1673
1674        assert_eq!(reply.kind, MessageKind::Error);
1675        let body = String::from_utf8_lossy(&reply.payload);
1676        assert!(
1677            body.contains("commit policy timed out") && body.contains("RED_COMMIT_FAIL_ON_TIMEOUT"),
1678            "request ack_n should wait for replica ack, got {body}"
1679        );
1680        let _ = std::fs::remove_file(data_path);
1681    }
1682
1683    #[test]
1684    fn redwire_query_with_params_rejects_request_policy_below_floor() {
1685        let _env_lock = env_lock().lock().expect("env lock");
1686        let _env = EnvGuard::set(&[("RED_PRIMARY_COMMIT_POLICY", "quorum")]);
1687        let runtime = RedDBRuntime::in_memory().expect("runtime");
1688
1689        let payload = reddb_wire::query_with_params::encode_query_with_params_request(
1690            "INSERT INTO redwire_request_floor (id, name) VALUES (1, 'alpha')",
1691            &[],
1692            &reddb_wire::query_with_params::QueryWithParamsOptions {
1693                commit_policy: Some("local".to_string()),
1694            },
1695        )
1696        .expect("encode query with request policy");
1697        let frame = reddb_wire::redwire::build_query_with_params_frame(101, payload)
1698            .expect("query-with-params frame");
1699        let reply = run_query_with_params(&runtime, &frame);
1700
1701        assert_eq!(reply.kind, MessageKind::Error);
1702        let body = String::from_utf8_lossy(&reply.payload);
1703        assert!(
1704            body.contains("COMMIT_POLICY_BELOW_FLOOR"),
1705            "typed floor violation should be surfaced, got {body}"
1706        );
1707    }
1708
1709    // ── Issue #587 — BatchInsertEndpoint RedWire mirror ──────────────
1710    //
1711    // The brief carries the rows + idempotency key in the existing
1712    // `BulkInsert` (0x04) frame: the presence of `idempotency_key` in
1713    // the JSON payload flips the dispatch onto the analytics batch
1714    // path (all-or-nothing commit, AnalyticsSchemaRegistry validation,
1715    // process-wide cache shared with HTTP #582 and gRPC #585). Each
1716    // test below maps to one acceptance bullet.
1717
1718    /// Bullet 1 — wire form: `BulkInsert` payload with
1719    /// `idempotency_key` routes to the batch path; success returns a
1720    /// `BulkOk` frame carrying `{"ok":true,"count":N}`. Bullet 5 —
1721    /// every row commits in submission order (we read them back and
1722    /// assert ascending storage order matches insertion order).
1723    #[test]
1724    fn redwire_batch_insert_happy_path_returns_bulkok_with_count() {
1725        let runtime = RedDBRuntime::in_memory().expect("runtime");
1726        runtime
1727            .execute_query("CREATE TABLE events_587_ok (id INTEGER, name TEXT)")
1728            .expect("create table");
1729
1730        let frame = bulk_insert_frame(
1731            100,
1732            br#"{
1733                "collection":"events_587_ok",
1734                "idempotency_key":"k-ok",
1735                "payloads":[
1736                    {"fields":{"id":1,"name":"a"}},
1737                    {"fields":{"id":2,"name":"b"}},
1738                    {"fields":{"id":3,"name":"c"}}
1739                ]
1740            }"#
1741            .to_vec(),
1742        );
1743        let reply = run_insert_dispatch(&runtime, &frame);
1744        assert_eq!(
1745            reply.kind,
1746            MessageKind::BulkOk,
1747            "body={:?}",
1748            String::from_utf8_lossy(&reply.payload)
1749        );
1750        let body: JsonValue = serde_json::from_slice(&reply.payload).expect("ok body json");
1751        assert_eq!(body.get("ok").and_then(JsonValue::as_bool), Some(true));
1752        assert_eq!(body.get("count").and_then(JsonValue::as_u64), Some(3));
1753
1754        // Submission-order commit — every row landed and the scan can
1755        // see them all. (CDC ordering is a property of
1756        // `create_rows_batch`, which the shared
1757        // `process_batch_insert` re-uses; we pin the user-observable
1758        // surface here.)
1759        let qr = runtime
1760            .execute_query("SELECT name FROM events_587_ok ORDER BY id ASC")
1761            .expect("scan");
1762        let names: Vec<String> = qr
1763            .result
1764            .records
1765            .iter()
1766            .filter_map(|record| match record.get("name") {
1767                Some(crate::storage::schema::Value::Text(s)) => Some(s.to_string()),
1768                _ => None,
1769            })
1770            .collect();
1771        assert_eq!(names, vec!["a", "b", "c"]);
1772    }
1773
1774    /// Bullet 3 — row K's failure rolls back the whole batch; the
1775    /// reply is an `Error` frame whose JSON body carries the failing
1776    /// `row_index` so the client can pinpoint the broken row without
1777    /// re-uploading.
1778    #[test]
1779    fn redwire_batch_insert_row_failure_rolls_back_with_row_index() {
1780        let runtime = RedDBRuntime::in_memory().expect("runtime");
1781        runtime
1782            .execute_query("CREATE TABLE events_587_rollback (id INTEGER, name TEXT)")
1783            .expect("create table");
1784
1785        // Row index 1 omits the required `fields` envelope — the parse
1786        // step rejects before any commit fires.
1787        let frame = bulk_insert_frame(
1788            101,
1789            br#"{
1790                "collection":"events_587_rollback",
1791                "idempotency_key":"k-rollback",
1792                "payloads":[
1793                    {"fields":{"id":1,"name":"a"}},
1794                    {"not_fields":{"id":2}},
1795                    {"fields":{"id":3,"name":"c"}}
1796                ]
1797            }"#
1798            .to_vec(),
1799        );
1800        let reply = run_insert_dispatch(&runtime, &frame);
1801        assert_eq!(reply.kind, MessageKind::Error);
1802        let body: JsonValue = serde_json::from_slice(&reply.payload).expect("err body json");
1803        assert_eq!(body.get("ok").and_then(JsonValue::as_bool), Some(false));
1804        assert_eq!(
1805            body.get("code").and_then(JsonValue::as_str),
1806            Some("RowParseFailure")
1807        );
1808        assert_eq!(body.get("row_index").and_then(JsonValue::as_u64), Some(1));
1809
1810        // Storage untouched — row 0 was never committed even though
1811        // it would have parsed cleanly on its own.
1812        let qr = runtime
1813            .execute_query("SELECT name FROM events_587_rollback")
1814            .expect("scan");
1815        assert!(
1816            qr.result.records.is_empty(),
1817            "row 0 leaked despite row 1 rejection: {} rows present",
1818            qr.result.records.len()
1819        );
1820    }
1821
1822    /// Bullet 2 — `idempotency_key` carried in the frame; the
1823    /// process-wide cache (shared with HTTP slice 4) replays a
1824    /// previous success byte-for-byte even when the retry's body
1825    /// differs from the original. The HTTP slice 4 already pins the
1826    /// cross-call behaviour at its boundary; this test pins the
1827    /// RedWire boundary plus the cross-transport sharing (a retry on
1828    /// the same key via HTTP returns the body RedWire just produced).
1829    #[test]
1830    fn redwire_batch_insert_idempotency_key_replays_cached_result() {
1831        let runtime = RedDBRuntime::in_memory().expect("runtime");
1832        runtime
1833            .execute_query("CREATE TABLE events_587_dedup (id INTEGER, name TEXT)")
1834            .expect("create table");
1835
1836        // Use a process-unique key so this test doesn't trample
1837        // (or get trampled by) the HTTP-side dedup test that shares
1838        // the global cache.
1839        let key = format!(
1840            "redwire-587-{}",
1841            std::time::SystemTime::now()
1842                .duration_since(std::time::UNIX_EPOCH)
1843                .unwrap()
1844                .as_nanos()
1845        );
1846
1847        let frame1 = bulk_insert_frame(
1848            200,
1849            format!(
1850                r#"{{
1851                    "collection":"events_587_dedup",
1852                    "idempotency_key":"{key}",
1853                    "payloads":[{{"fields":{{"id":1,"name":"first"}}}}]
1854                }}"#
1855            )
1856            .into_bytes(),
1857        );
1858        let reply1 = run_insert_dispatch(&runtime, &frame1);
1859        assert_eq!(reply1.kind, MessageKind::BulkOk);
1860        let body1 = reply1.payload.clone();
1861
1862        // Replay with the same key + DIFFERENT body — the cache
1863        // returns the original bytes verbatim and the second row is
1864        // not committed.
1865        let frame2 = bulk_insert_frame(
1866            201,
1867            format!(
1868                r#"{{
1869                    "collection":"events_587_dedup",
1870                    "idempotency_key":"{key}",
1871                    "payloads":[{{"fields":{{"id":2,"name":"second"}}}}]
1872                }}"#
1873            )
1874            .into_bytes(),
1875        );
1876        let reply2 = run_insert_dispatch(&runtime, &frame2);
1877        assert_eq!(reply2.kind, MessageKind::BulkOk);
1878        assert_eq!(
1879            reply2.payload, body1,
1880            "replay must return cached body byte-for-byte"
1881        );
1882
1883        let qr = runtime
1884            .execute_query("SELECT name FROM events_587_dedup")
1885            .expect("scan");
1886        assert_eq!(
1887            qr.result.records.len(),
1888            1,
1889            "replay re-executed and committed the second row"
1890        );
1891    }
1892
1893    /// Bullet 2 (cont.) — the cache is *shared with HTTP slice 4*: a
1894    /// RedWire submission populates the cache, and a same-key HTTP
1895    /// retry returns the cached body verbatim.
1896    #[test]
1897    fn redwire_batch_insert_cache_shared_with_http_transport() {
1898        use crate::runtime::batch_insert::global_cache;
1899
1900        let runtime = RedDBRuntime::in_memory().expect("runtime");
1901        runtime
1902            .execute_query("CREATE TABLE events_587_shared (id INTEGER, name TEXT)")
1903            .expect("create table");
1904
1905        let key = format!(
1906            "shared-cache-587-{}",
1907            std::time::SystemTime::now()
1908                .duration_since(std::time::UNIX_EPOCH)
1909                .unwrap()
1910                .as_nanos()
1911        );
1912
1913        let frame = bulk_insert_frame(
1914            300,
1915            format!(
1916                r#"{{
1917                    "collection":"events_587_shared",
1918                    "idempotency_key":"{key}",
1919                    "payloads":[{{"fields":{{"id":1,"name":"x"}}}}]
1920                }}"#
1921            )
1922            .into_bytes(),
1923        );
1924        let reply = run_insert_dispatch(&runtime, &frame);
1925        assert_eq!(reply.kind, MessageKind::BulkOk);
1926
1927        // Look the entry up directly via the process-wide cache that
1928        // both HTTP and RedWire share. A hit here is the entire
1929        // "shared with HTTP slice 4" contract.
1930        let hit = global_cache()
1931            .lookup("events_587_shared", &key, std::time::Instant::now())
1932            .expect("shared cache must serve the RedWire write to HTTP");
1933        assert_eq!(hit.status, 200);
1934        assert_eq!(hit.body, reply.payload);
1935    }
1936
1937    /// Bullet 4 — schema-validation failure mirrors the other
1938    /// transports: a row that the `AnalyticsSchemaRegistry` rejects
1939    /// surfaces as `RowSchemaRejected` with the offending `row_index`,
1940    /// and the batch leaves the collection untouched.
1941    #[test]
1942    fn redwire_batch_insert_schema_validation_rejects_unknown_field() {
1943        use crate::runtime::analytics_schema_registry as reg;
1944
1945        let runtime = RedDBRuntime::in_memory().expect("runtime");
1946        runtime
1947            .execute_query("CREATE TABLE events_587_schema (event_name TEXT, payload TEXT)")
1948            .expect("create table");
1949
1950        let schema =
1951            r#"{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}"#;
1952        reg::register(runtime.db().store().as_ref(), "click_587", schema).expect("register schema");
1953
1954        let frame = bulk_insert_frame(
1955            400,
1956            br#"{
1957                "collection":"events_587_schema",
1958                "idempotency_key":"k-schema",
1959                "payloads":[
1960                    {"fields":{"event_name":"click_587","payload":"{\"url\":\"/a\"}"}},
1961                    {"fields":{"event_name":"click_587","payload":"{\"url\":\"/b\",\"extra\":1}"}}
1962                ]
1963            }"#
1964            .to_vec(),
1965        );
1966        let reply = run_insert_dispatch(&runtime, &frame);
1967        assert_eq!(reply.kind, MessageKind::Error);
1968        let body: JsonValue = serde_json::from_slice(&reply.payload).expect("err body json");
1969        assert_eq!(
1970            body.get("code").and_then(JsonValue::as_str),
1971            Some("RowSchemaRejected")
1972        );
1973        assert_eq!(body.get("row_index").and_then(JsonValue::as_u64), Some(1));
1974
1975        let qr = runtime
1976            .execute_query("SELECT event_name FROM events_587_schema")
1977            .expect("scan");
1978        assert!(
1979            qr.result.records.is_empty(),
1980            "row 0 leaked despite row 1 schema rejection"
1981        );
1982    }
1983
1984    /// Bullet 4 (cont.) — oversize fails with `BatchTooLarge` and a
1985    /// 413-equivalent status; the storage is never touched.
1986    ///
1987    /// Build one row past the default ceiling rather than mutating
1988    /// `RED_BATCH_MAX_ROWS`. The env var is process-wide and the
1989    /// `cargo test` runner schedules tests in parallel; a `set_var`
1990    /// here leaks into sibling tests in this crate (e.g. the
1991    /// row-failure case sees its 3-row batch flagged as oversize).
1992    /// The HTTP slice 4 test takes the same "build past the default"
1993    /// route for the same reason.
1994    #[test]
1995    fn redwire_batch_insert_oversize_returns_error_before_storage() {
1996        let runtime = RedDBRuntime::in_memory().expect("runtime");
1997        runtime
1998            .execute_query("CREATE TABLE events_587_oversize (id INTEGER, name TEXT)")
1999            .expect("create table");
2000
2001        // Default `red.batch.max_rows = 10_000`; submit one more.
2002        let max = 10_000usize;
2003        let mut payloads = String::with_capacity(max * 32);
2004        payloads.push('[');
2005        for i in 0..(max + 1) {
2006            if i > 0 {
2007                payloads.push(',');
2008            }
2009            payloads.push_str(&format!(r#"{{"fields":{{"id":{i},"name":"x"}}}}"#));
2010        }
2011        payloads.push(']');
2012        let frame_body = format!(
2013            r#"{{"collection":"events_587_oversize","idempotency_key":"k-oversize-587","payloads":{payloads}}}"#
2014        );
2015        let frame = bulk_insert_frame(500, frame_body.into_bytes());
2016        let reply = run_insert_dispatch(&runtime, &frame);
2017
2018        assert_eq!(reply.kind, MessageKind::Error);
2019        let body: JsonValue = serde_json::from_slice(&reply.payload).expect("err body json");
2020        assert_eq!(
2021            body.get("code").and_then(JsonValue::as_str),
2022            Some("BatchTooLarge")
2023        );
2024        let qr = runtime
2025            .execute_query("SELECT name FROM events_587_oversize")
2026            .expect("scan");
2027        assert!(
2028            qr.result.records.is_empty(),
2029            "oversize batch leaked rows into storage"
2030        );
2031    }
2032}
2033
2034#[cfg(test)]
2035mod session_bulk_stream_tests;