From 2df69d9c4aa159a1269a6616f24fd5b31222dc89 Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:23:27 +0800 Subject: [PATCH 1/5] fix(strict-format): coerce ARRAY vector literals into a strict VECTOR ARRAY[0.1, 0.2, 0.3] folds its literals to Decimal. extract_vector_floats kept Float and Integer, dropped the rest, and reported "got 0 elements" as XX000. - accept Float, Integer, Decimal, and numeric String elements - reject a non-numeric element by index and type - refuse non-finite values - preserve BadRequest at the binary_tuple wrap sites, so a client value error never surfaces as XX000 --- .../handlers/point/apply_put/stored_body.rs | 2 +- .../handlers/transaction/stage_write/body.rs | 4 +- .../transaction/stage_write/stage_upsert.rs | 2 +- .../src/data/executor/strict_format/coerce.rs | 107 ++++++++++++++++-- 4 files changed, 101 insertions(+), 14 deletions(-) diff --git a/nodedb/src/data/executor/handlers/point/apply_put/stored_body.rs b/nodedb/src/data/executor/handlers/point/apply_put/stored_body.rs index 63c8a0e11..5180a2f15 100644 --- a/nodedb/src/data/executor/handlers/point/apply_put/stored_body.rs +++ b/nodedb/src/data/executor/handlers/point/apply_put/stored_body.rs @@ -127,7 +127,7 @@ impl CoreLoop { strict_format::bytes_to_binary_tuple(&value, schema, collection) } .map_err(|e| match e { - crate::Error::UnknownStrictField { .. } => e, + crate::Error::UnknownStrictField { .. } | crate::Error::BadRequest { .. } => e, other => crate::Error::Serialization { format: "binary_tuple".into(), detail: other.to_string(), diff --git a/nodedb/src/data/executor/handlers/transaction/stage_write/body.rs b/nodedb/src/data/executor/handlers/transaction/stage_write/body.rs index 979a46b07..8dd5e3578 100644 --- a/nodedb/src/data/executor/handlers/transaction/stage_write/body.rs +++ b/nodedb/src/data/executor/handlers/transaction/stage_write/body.rs @@ -126,7 +126,7 @@ impl CoreLoop { strict_format::bytes_to_binary_tuple(&encoded_input, schema, collection) } .map_err(|e| match e { - crate::Error::UnknownStrictField { .. } => e, + crate::Error::UnknownStrictField { .. } | crate::Error::BadRequest { .. } => e, other => crate::Error::Serialization { format: "binary_tuple".into(), detail: other.to_string(), @@ -245,7 +245,7 @@ impl CoreLoop { strict_format::value_to_binary_tuple(&ndb_val, schema, collection) } .map_err(|e| match e { - crate::Error::UnknownStrictField { .. } => e, + crate::Error::UnknownStrictField { .. } | crate::Error::BadRequest { .. } => e, other => crate::Error::Serialization { format: "binary_tuple".into(), detail: other.to_string(), diff --git a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_upsert.rs b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_upsert.rs index 1d100b7d3..562072666 100644 --- a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_upsert.rs +++ b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_upsert.rs @@ -175,7 +175,7 @@ impl CoreLoop { strict_format::value_to_binary_tuple(&merged, schema, ctx.collection) }; result.map_err(|e| match e { - crate::Error::UnknownStrictField { .. } => e, + crate::Error::UnknownStrictField { .. } | crate::Error::BadRequest { .. } => e, other => crate::Error::Serialization { format: "binary_tuple".into(), detail: other.to_string(), diff --git a/nodedb/src/data/executor/strict_format/coerce.rs b/nodedb/src/data/executor/strict_format/coerce.rs index d2ec8eec1..1e20a8d3e 100644 --- a/nodedb/src/data/executor/strict_format/coerce.rs +++ b/nodedb/src/data/executor/strict_format/coerce.rs @@ -166,7 +166,7 @@ pub fn coerce_value(val: &Value, col_type: &ColumnType, col_name: &str) -> crate ColumnType::Vector(dim) => match val { Value::Bytes(b) if b.len() == *dim as usize * 4 => Ok(val.clone()), Value::Array(arr) => { - let floats = extract_vector_floats(arr); + let floats = extract_vector_floats(col_name, arr)?; validate_and_encode_vector(col_name, *dim, &floats) } Value::String(s) => { @@ -229,15 +229,55 @@ pub fn coerce_value(val: &Value, col_type: &ColumnType, col_name: &str) -> crate } } -/// Extract f32 floats from a `Value::Array`. -fn extract_vector_floats(arr: &[Value]) -> Vec { - arr.iter() - .filter_map(|v| match v { - Value::Float(f) => Some(*f as f32), - Value::Integer(n) => Some(*n as f32), - _ => None, - }) - .collect() +/// Extract `f32` floats from a `Value::Array` for a VECTOR column. +/// +/// Accepts the element shapes the planner actually produces for an +/// `ARRAY[...]` literal — `Float`, `Integer`, `Decimal` (a folded numeric +/// literal arrives as `Decimal`), and numeric `String` — matching the +/// schemaless point-put path (`vector_string::floats_from_value`), so one +/// literal behaves the same on every engine. +/// +/// A non-numeric element is rejected by index and type. Dropping one silently +/// would report the literal as having fewer elements than the user wrote and +/// surface the mismatch as an internal error. +fn extract_vector_floats(col_name: &str, arr: &[Value]) -> crate::Result> { + let mut floats = Vec::with_capacity(arr.len()); + for (i, v) in arr.iter().enumerate() { + let f = match v { + Value::Float(f) => *f as f32, + Value::Integer(n) => *n as f32, + Value::Decimal(d) => { + use rust_decimal::prelude::ToPrimitive; + d.to_f32().ok_or_else(|| crate::Error::BadRequest { + detail: format!( + "column '{col_name}': VECTOR element {i}: cannot represent DECIMAL {d} as FLOAT" + ), + })? + } + Value::String(s) => s.parse::().map_err(|_| crate::Error::BadRequest { + detail: format!( + "column '{col_name}': VECTOR element {i}: expected a numeric element, got String({s:?})" + ), + })?, + other => { + return Err(crate::Error::BadRequest { + detail: format!( + "column '{col_name}': VECTOR element {i}: expected a numeric element, got {}", + other.type_name() + ), + }); + } + }; + if !f.is_finite() { + return Err(crate::Error::BadRequest { + detail: format!( + "column '{col_name}': VECTOR element {i}: expected a finite element, got {f}" + ), + }); + } + floats.push(f); + } + Ok(floats) } /// Validate dimension count and encode as little-endian bytes. @@ -415,4 +455,51 @@ mod tests { let msg = result.unwrap_err().to_string(); assert!(msg.contains("\"extra\"") && msg.contains("does not exist")); } + + #[test] + fn vector_accepts_decimal_and_numeric_string_elements() { + // `ARRAY[0.1, 0.2, 0.3]` folds its numeric literals to `Decimal` + // elements; numeric strings are accepted like the schemaless path. + let arr = vec![ + Value::Decimal(rust_decimal::Decimal::new(1, 1)), + Value::String("0.2".into()), + Value::Integer(3), + ]; + let floats = extract_vector_floats("embedding", &arr).unwrap(); + assert_eq!(floats.len(), 3); + assert!((floats[0] - 0.1).abs() < 1e-6, "{floats:?}"); + assert!((floats[1] - 0.2).abs() < 1e-6, "{floats:?}"); + assert!((floats[2] - 3.0).abs() < 1e-6, "{floats:?}"); + } + + #[test] + fn vector_element_error_names_index_and_type() { + let arr = vec![Value::Float(0.1), Value::String("x".into())]; + let err = extract_vector_floats("embedding", &arr) + .unwrap_err() + .to_string(); + assert!(err.contains("VECTOR element 1"), "{err}"); + assert!(err.contains("String"), "{err}"); + } + + #[test] + fn vector_rejects_non_finite_elements() { + let arr = vec![Value::Float(f64::NAN)]; + assert!(extract_vector_floats("embedding", &arr).is_err()); + } + + #[test] + fn vector_encodes_three_decimals_at_full_width() { + let arr = vec![ + Value::Decimal(rust_decimal::Decimal::new(1, 1)), + Value::Decimal(rust_decimal::Decimal::new(2, 1)), + Value::Decimal(rust_decimal::Decimal::new(3, 1)), + ]; + let floats = extract_vector_floats("embedding", &arr).unwrap(); + let encoded = validate_and_encode_vector("embedding", 3, &floats).unwrap(); + match encoded { + Value::Bytes(b) => assert_eq!(b.len(), 12), + other => panic!("expected Bytes, got {other:?}"), + } + } } From b7e1243f5cc3d40e9c34f916880b791d0a976001 Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:55:07 +0800 Subject: [PATCH 2/5] test(strict-format): cover ARRAY vector literals on a document_strict schema --- .../cases/engine_surface_document_strict.rs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/nodedb/tests/wire/cases/engine_surface_document_strict.rs b/nodedb/tests/wire/cases/engine_surface_document_strict.rs index f01a62cee..f98bcf0b6 100644 --- a/nodedb/tests/wire/cases/engine_surface_document_strict.rs +++ b/nodedb/tests/wire/cases/engine_surface_document_strict.rs @@ -146,3 +146,58 @@ async fn wal_restart_durability() { assert_eq!(rows.len(), 1); assert_eq!(rows[0][0], "persisted"); } + +/// Create a `document_strict` collection with a `VECTOR(3)` column, the shape +/// the `ARRAY[...]` literal case needs. +async fn create_strict_vector(server: &TestServer, name: &str) { + server + .exec(&format!( + "CREATE COLLECTION {name} (id TEXT PRIMARY KEY, embedding VECTOR(3)) \ + WITH (engine = 'document_strict')" + )) + .await + .unwrap_or_else(|e| panic!("create {name}: {e}")); +} + +/// The planner folds `ARRAY[0.1, 0.2, 0.3]` to `Decimal` elements. Strict +/// coercion accepted only `Float` and `Integer`, so the literal was reported as +/// having 0 elements and the mismatch surfaced as an internal error. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn document_strict_inserts_a_decimal_array_literal_into_a_vector_column() { + let server = TestServer::start().await; + create_strict_vector(&server, "vec_array_literal").await; + + server + .exec("INSERT INTO vec_array_literal (id, embedding) VALUES ('a1', ARRAY[0.1, 0.2, 0.3])") + .await + .expect("ARRAY[0.1, 0.2, 0.3] must insert into a VECTOR(3) column"); + + let rows = server + .query_rows("SELECT id FROM vec_array_literal") + .await + .unwrap(); + assert_eq!(rows.len(), 1, "the row must be stored: {rows:?}"); +} + +/// An element the coercion cannot read is a value error: it names the element +/// and its type, and it never surfaces as `XX000`. The house mapping for a +/// bad request is `42601`; the assertion pins the code so a reclassification +/// cannot regress silently. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn document_strict_reports_a_non_numeric_element_by_index_and_type() { + let server = TestServer::start().await; + create_strict_vector(&server, "vec_array_bad").await; + + server + .expect_error( + "INSERT INTO vec_array_bad (id, embedding) VALUES ('b1', ARRAY[0.1, 'nope', 0.3])", + "VECTOR element 1", + ) + .await; + server + .expect_error( + "INSERT INTO vec_array_bad (id, embedding) VALUES ('b2', ARRAY[0.1, 'nope', 0.3])", + "SQLSTATE 42601", + ) + .await; +} From 42971dccb9fd6e830fc81159a349836f65e3e3e9 Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:14:48 +0800 Subject: [PATCH 3/5] fix(bridge): carry a bad request across the Data Plane boundary Error::BadRequest fell into the bridge's catch-all and reached the client as XX000. A client value error is not an internal fault. - ErrorCode::BadRequest mirrors the crate error across the Data Plane and the cluster wire (appended variant, existing slots untouched) - pgwire renders it 42601, the code the direct path already assigns - write-abort treats it as not established: the same code can be raised after an earlier step of a multi-step plan applied --- nodedb-cluster/src/rpc_codec/data_plane_error.rs | 6 ++++++ nodedb/src/bridge/envelope/error_code.rs | 7 +++++++ nodedb/src/control/cluster/data_plane_error_wire.rs | 2 ++ nodedb/src/control/server/dispatch_utils/write_abort.rs | 7 ++++++- nodedb/src/control/server/shared/ddl/sqlstate.rs | 1 + nodedb/src/error_from_data_plane.rs | 1 + 6 files changed, 23 insertions(+), 1 deletion(-) diff --git a/nodedb-cluster/src/rpc_codec/data_plane_error.rs b/nodedb-cluster/src/rpc_codec/data_plane_error.rs index 45927be57..cb33b26f1 100644 --- a/nodedb-cluster/src/rpc_codec/data_plane_error.rs +++ b/nodedb-cluster/src/rpc_codec/data_plane_error.rs @@ -121,4 +121,10 @@ pub enum DataPlaneErrorCode { status_column: String, row_identity: String, }, + /// A request the client can fix (malformed value, unreadable literal + /// element). Crosses verbatim so the coordinator answers `42601` instead of + /// the generic `XX000` an `Internal` renders. + BadRequest { + detail: String, + }, } diff --git a/nodedb/src/bridge/envelope/error_code.rs b/nodedb/src/bridge/envelope/error_code.rs index b0a115121..1ea708086 100644 --- a/nodedb/src/bridge/envelope/error_code.rs +++ b/nodedb/src/bridge/envelope/error_code.rs @@ -107,6 +107,12 @@ pub enum ErrorCode { /// this op-type. Distinguished from `Internal` so pgwire surfaces it as /// `0A000` (feature_not_supported) rather than `XX000`. Unsupported { detail: String }, + /// A request the client can fix: a malformed value, an unreadable literal + /// element, a clause that names nothing. Distinct from `Internal` so it + /// survives the Data Plane → pgwire boundary and reaches the client as + /// `42601` (syntax_error) — the SQLSTATE the direct path already assigns + /// `crate::Error::BadRequest` — rather than the generic `XX000`. + BadRequest { detail: String }, /// Transaction rollback failed: at least one undo entry could not be /// applied. The shard state is unknown — the client must treat this as a /// fatal error and the operator must restart the shard (WAL replay restores @@ -222,6 +228,7 @@ impl From for ErrorCode { Self::TxnOverlayMemoryExceeded { limit } } crate::Error::DivisionByZero => Self::DivisionByZero, + crate::Error::BadRequest { detail } => Self::BadRequest { detail }, crate::Error::UndefinedColumn { column } => Self::UndefinedColumn { column }, // Same condition an undefined column reports at plan time, raised // here by the strict encoder for a transport the planner never diff --git a/nodedb/src/control/cluster/data_plane_error_wire.rs b/nodedb/src/control/cluster/data_plane_error_wire.rs index 2ad97269b..c35e9d58f 100644 --- a/nodedb/src/control/cluster/data_plane_error_wire.rs +++ b/nodedb/src/control/cluster/data_plane_error_wire.rs @@ -136,6 +136,7 @@ impl From for DataPlaneErrorCode { ErrorCode::UndefinedColumn { column } => Self::UndefinedColumn { column }, ErrorCode::Internal { detail } => Self::Internal { detail }, ErrorCode::Unsupported { detail } => Self::Unsupported { detail }, + ErrorCode::BadRequest { detail } => Self::BadRequest { detail }, ErrorCode::RollbackFailed { entry_index, detail, @@ -235,6 +236,7 @@ impl From for ErrorCode { DataPlaneErrorCode::UndefinedColumn { column } => Self::UndefinedColumn { column }, DataPlaneErrorCode::Internal { detail } => Self::Internal { detail }, DataPlaneErrorCode::Unsupported { detail } => Self::Unsupported { detail }, + DataPlaneErrorCode::BadRequest { detail } => Self::BadRequest { detail }, DataPlaneErrorCode::RollbackFailed { entry_index, detail, diff --git a/nodedb/src/control/server/dispatch_utils/write_abort.rs b/nodedb/src/control/server/dispatch_utils/write_abort.rs index 373523bef..7004a92c3 100644 --- a/nodedb/src/control/server/dispatch_utils/write_abort.rs +++ b/nodedb/src/control/server/dispatch_utils/write_abort.rs @@ -166,6 +166,10 @@ pub(crate) fn write_definitely_not_applied(code: &ErrorCode) -> bool { // * `DuplicateWrite` — the idempotency gate fired because the write // ALREADY applied under the original request; nothing to undo, and // the duplicate record replays to the same state. + // * `BadRequest` — a validation verdict on the path that raised it, but + // the same code also comes from a phase that can run after an earlier + // step of a multi-step plan applied, so refusal-without-apply is not + // established by the code alone. ErrorCode::DeadlineExceeded | ErrorCode::RollbackFailed { .. } | ErrorCode::ResourcesExhausted @@ -173,7 +177,8 @@ pub(crate) fn write_definitely_not_applied(code: &ErrorCode) -> bool { | ErrorCode::CrdtFrontierMismatch { .. } | ErrorCode::FanOutExceeded | ErrorCode::RecursionDepthExceeded { .. } - | ErrorCode::DuplicateWrite => false, + | ErrorCode::DuplicateWrite + | ErrorCode::BadRequest { .. } => false, } } diff --git a/nodedb/src/control/server/shared/ddl/sqlstate.rs b/nodedb/src/control/server/shared/ddl/sqlstate.rs index 4a3a4c0da..fbe760ee9 100644 --- a/nodedb/src/control/server/shared/ddl/sqlstate.rs +++ b/nodedb/src/control/server/shared/ddl/sqlstate.rs @@ -190,6 +190,7 @@ pub fn error_code_to_sqlstate(code: &ErrorCode) -> (&'static str, &'static str, ErrorCode::Unsupported { detail } => { ("ERROR", sqlstate::FEATURE_NOT_SUPPORTED, detail.clone()) } + ErrorCode::BadRequest { detail } => ("ERROR", sqlstate::SYNTAX_ERROR, detail.clone()), ErrorCode::RollbackFailed { entry_index, detail, diff --git a/nodedb/src/error_from_data_plane.rs b/nodedb/src/error_from_data_plane.rs index 46ac98e1f..be77d339a 100644 --- a/nodedb/src/error_from_data_plane.rs +++ b/nodedb/src/error_from_data_plane.rs @@ -129,6 +129,7 @@ pub(crate) fn data_plane_code_to_public(code: ErrorCode) -> NodeDbError { )), ErrorCode::UndefinedColumn { column } => NodeDbError::undefined_column(column), ErrorCode::Unsupported { detail } => NodeDbError::bad_request(detail), + ErrorCode::BadRequest { detail } => NodeDbError::bad_request(detail), ErrorCode::DivisionByZero => NodeDbError::division_by_zero(), ErrorCode::TxnOverlayMemoryExceeded { limit } => NodeDbError::bad_request(format!( "transaction staging overlay exceeded its {limit}-byte per-core budget; \ From e30cc23a1bd6fda12ea794a0ab7baebc6c0d2ea3 Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:54:09 +0800 Subject: [PATCH 4/5] fix(executor): keep a bad request out of XX000 on the convert and post-image doors Two strict-encoder wrap sites still moved a client value error into ErrorCode::Internal while the sibling sites preserved it. Mirror the sibling arm so a bad value keeps its 42601 on every path, and pin both directions: the abort classifier never treats a bad request as definitely-unapplied, and the cluster wire round-trips it verbatim. --- .../control/cluster/data_plane_error_wire.rs | 11 +++++++++++ .../server/dispatch_utils/write_abort.rs | 3 +++ nodedb/src/data/executor/handlers/convert.rs | 17 +++++++++-------- .../handlers/point/update/post_image.rs | 1 + .../cases/engine_surface_document_strict.rs | 6 +++--- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/nodedb/src/control/cluster/data_plane_error_wire.rs b/nodedb/src/control/cluster/data_plane_error_wire.rs index c35e9d58f..00d76854b 100644 --- a/nodedb/src/control/cluster/data_plane_error_wire.rs +++ b/nodedb/src/control/cluster/data_plane_error_wire.rs @@ -275,6 +275,17 @@ mod tests { assert_eq!(ErrorCode::from(wire), original); } + /// A client value error keeps its detail across the node hop, so the + /// coordinator renders the same `42601` a single node renders. + #[test] + fn a_bad_request_roundtrips_verbatim() { + let original = ErrorCode::BadRequest { + detail: "column 'embedding': VECTOR element 1: expected a numeric element".into(), + }; + let wire = DataPlaneErrorCode::from(original.clone()); + assert_eq!(ErrorCode::from(wire), original); + } + #[test] fn execution_error_keeps_a_data_plane_verdict_typed() { let typed = execution_error_to_typed(crate::Error::DataPlane(ErrorCode::DivisionByZero)); diff --git a/nodedb/src/control/server/dispatch_utils/write_abort.rs b/nodedb/src/control/server/dispatch_utils/write_abort.rs index 7004a92c3..b94821933 100644 --- a/nodedb/src/control/server/dispatch_utils/write_abort.rs +++ b/nodedb/src/control/server/dispatch_utils/write_abort.rs @@ -226,5 +226,8 @@ mod tests { detail: "io_uring".into(), })); assert!(!write_definitely_not_applied(&ErrorCode::DuplicateWrite)); + assert!(!write_definitely_not_applied(&ErrorCode::BadRequest { + detail: "column 'embedding': expected a numeric element".into(), + })); } } diff --git a/nodedb/src/data/executor/handlers/convert.rs b/nodedb/src/data/executor/handlers/convert.rs index c9abc2171..8f1c0341c 100644 --- a/nodedb/src/data/executor/handlers/convert.rs +++ b/nodedb/src/data/executor/handlers/convert.rs @@ -155,14 +155,15 @@ impl CoreLoop { ) { Ok(bytes) => bytes, Err(e) => { - return self.response_error( - task, - ErrorCode::Internal { - detail: format!( - "collection '{collection}': row '{identity}' failed to convert to document_strict: {e}" - ), - }, - ); + let code = match e { + crate::Error::BadRequest { detail } => ErrorCode::BadRequest { detail }, + other => ErrorCode::Internal { + detail: format!( + "collection '{collection}': row '{identity}' failed to convert to document_strict: {other}" + ), + }, + }; + return self.response_error(task, code); } }; diff --git a/nodedb/src/data/executor/handlers/point/update/post_image.rs b/nodedb/src/data/executor/handlers/point/update/post_image.rs index 969e1ce5d..3e41dabef 100644 --- a/nodedb/src/data/executor/handlers/point/update/post_image.rs +++ b/nodedb/src/data/executor/handlers/point/update/post_image.rs @@ -107,6 +107,7 @@ impl CoreLoop { crate::Error::UnknownStrictField { column, .. } => { ErrorCode::UndefinedColumn { column } } + crate::Error::BadRequest { detail } => ErrorCode::BadRequest { detail }, other => ErrorCode::Internal { detail: format!("strict re-encode: {other}"), }, diff --git a/nodedb/tests/wire/cases/engine_surface_document_strict.rs b/nodedb/tests/wire/cases/engine_surface_document_strict.rs index f98bcf0b6..c900c2318 100644 --- a/nodedb/tests/wire/cases/engine_surface_document_strict.rs +++ b/nodedb/tests/wire/cases/engine_surface_document_strict.rs @@ -159,9 +159,9 @@ async fn create_strict_vector(server: &TestServer, name: &str) { .unwrap_or_else(|e| panic!("create {name}: {e}")); } -/// The planner folds `ARRAY[0.1, 0.2, 0.3]` to `Decimal` elements. Strict -/// coercion accepted only `Float` and `Integer`, so the literal was reported as -/// having 0 elements and the mismatch surfaced as an internal error. +/// The planner folds `ARRAY[0.1, 0.2, 0.3]` to `Decimal` elements. Coercion +/// reads Float, Integer, Decimal and numeric String elements, so the literal +/// keeps its element count and reaches the column intact. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn document_strict_inserts_a_decimal_array_literal_into_a_vector_column() { let server = TestServer::start().await; From 654b7cc6d49171f94e38c55935bc035eec9c4bdc Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:00:55 +0800 Subject: [PATCH 5/5] fix(executor): keep a bad request typed on the update-from-join collect pass The collect pass encodes strict post-images, so a client value error can surface there. It was folded into ErrorCode::Internal; preserve it like the sibling doors so the client keeps 42601. --- .../src/data/executor/handlers/update_from_join.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/nodedb/src/data/executor/handlers/update_from_join.rs b/nodedb/src/data/executor/handlers/update_from_join.rs index 3cbd5b833..06a18aaca 100644 --- a/nodedb/src/data/executor/handlers/update_from_join.rs +++ b/nodedb/src/data/executor/handlers/update_from_join.rs @@ -144,12 +144,16 @@ impl CoreLoop { ) { Ok(r) => r, Err(e) => { - return self.response_error( - task, - ErrorCode::Internal { - detail: e.to_string(), + // The collect pass encodes strict post-images, so a client + // value error can reach here. Keep it typed: a bad value is + // not an internal fault. + let code = match e { + crate::Error::BadRequest { detail } => ErrorCode::BadRequest { detail }, + other => ErrorCode::Internal { + detail: other.to_string(), }, - ); + }; + return self.response_error(task, code); } };