@@ -216,6 +216,117 @@ pub async fn call<S: ControlStateDelegate + NodeDelegate>(
216216 }
217217}
218218
219+ /// Path parameters for the `call_from_database` route.
220+ #[ derive( Deserialize ) ]
221+ pub struct CallFromDatabaseParams {
222+ name_or_identity : NameOrIdentity ,
223+ reducer : String ,
224+ }
225+
226+ /// Query parameters for the `call_from_database` route.
227+ ///
228+ /// Both fields are mandatory; a missing field results in a 400 Bad Request.
229+ #[ derive( Deserialize ) ]
230+ pub struct CallFromDatabaseQuery {
231+ /// Hex-encoded [`Identity`] of the sending database.
232+ sender_identity : String ,
233+ /// The commitlog offset of the message on the sender side.
234+ /// Used for at-most-once delivery via `st_databases_tx_offset`.
235+ tx_offset : u64 ,
236+ }
237+
238+ /// Call a reducer on behalf of another database, with deduplication.
239+ ///
240+ /// Endpoint: `POST /database/:name_or_identity/call-from-database/:reducer`
241+ ///
242+ /// Required query params:
243+ /// - `sender_identity` — hex-encoded identity of the sending database.
244+ /// - `tx_offset` — the sender's commitlog offset for this message.
245+ ///
246+ /// Before invoking the reducer, the receiver checks `st_databases_tx_offset`.
247+ /// If the incoming `tx_offset` is ≤ the last delivered offset for `sender_identity`,
248+ /// the call is a duplicate and 200 OK is returned immediately without running the reducer.
249+ /// Otherwise the reducer is invoked, the dedup index is updated atomically in the same
250+ /// transaction, and an acknowledgment is returned on success.
251+ pub async fn call_from_database < S : ControlStateDelegate + NodeDelegate > (
252+ State ( worker_ctx) : State < S > ,
253+ Extension ( auth) : Extension < SpacetimeAuth > ,
254+ Path ( CallFromDatabaseParams {
255+ name_or_identity,
256+ reducer,
257+ } ) : Path < CallFromDatabaseParams > ,
258+ Query ( CallFromDatabaseQuery {
259+ sender_identity,
260+ tx_offset,
261+ } ) : Query < CallFromDatabaseQuery > ,
262+ TypedHeader ( content_type) : TypedHeader < headers:: ContentType > ,
263+ ByteStringBody ( body) : ByteStringBody ,
264+ ) -> axum:: response:: Result < impl IntoResponse > {
265+ assert_content_type_json ( content_type) ?;
266+
267+ let caller_identity = auth. claims . identity ;
268+
269+ let sender_identity = Identity :: from_hex ( & sender_identity)
270+ . map_err ( |_| ( StatusCode :: BAD_REQUEST , "Invalid sender_identity: expected hex-encoded identity" ) ) ?;
271+
272+ let args = FunctionArgs :: Json ( body) ;
273+ let connection_id = generate_random_connection_id ( ) ;
274+
275+ let ( module, Database { owner_identity, .. } ) = find_module_and_database ( & worker_ctx, name_or_identity) . await ?;
276+
277+ // Call client_connected, if defined.
278+ module
279+ . call_identity_connected ( auth. into ( ) , connection_id)
280+ . await
281+ . map_err ( client_connected_error_to_response) ?;
282+
283+ let result = module
284+ . call_reducer_from_database (
285+ caller_identity,
286+ Some ( connection_id) ,
287+ None ,
288+ None ,
289+ None ,
290+ & reducer,
291+ args,
292+ sender_identity,
293+ tx_offset,
294+ )
295+ . await ;
296+
297+ module
298+ . call_identity_disconnected ( caller_identity, connection_id)
299+ . await
300+ . map_err ( client_disconnected_error_to_response) ?;
301+
302+ match result {
303+ Ok ( rcr) => {
304+ let ( status, body) = match rcr. outcome {
305+ ReducerOutcome :: Committed => ( StatusCode :: OK , "" . into ( ) ) ,
306+ ReducerOutcome :: Deduplicated => ( StatusCode :: OK , "deduplicated" . into ( ) ) ,
307+ ReducerOutcome :: Failed ( errmsg) => ( StatusCode :: from_u16 ( 530 ) . unwrap ( ) , * errmsg) ,
308+ ReducerOutcome :: BudgetExceeded => {
309+ log:: warn!(
310+ "Node's energy budget exceeded for identity: {owner_identity} while executing {reducer}"
311+ ) ;
312+ ( StatusCode :: PAYMENT_REQUIRED , "Module energy budget exhausted." . into ( ) )
313+ }
314+ } ;
315+ Ok ( (
316+ status,
317+ TypedHeader ( SpacetimeEnergyUsed ( rcr. energy_used ) ) ,
318+ TypedHeader ( SpacetimeExecutionDurationMicros ( rcr. execution_duration ) ) ,
319+ body,
320+ )
321+ . into_response ( ) )
322+ }
323+ Err ( e) => {
324+ let ( status, msg) = map_reducer_error ( e, & reducer) ;
325+ Err ( ( status, msg) . into ( ) )
326+ }
327+ }
328+ }
329+
219330fn assert_content_type_json ( content_type : headers:: ContentType ) -> axum:: response:: Result < ( ) > {
220331 if content_type != headers:: ContentType :: json ( ) {
221332 Err ( axum:: extract:: rejection:: MissingJsonContentType :: default ( ) . into ( ) )
@@ -230,7 +341,7 @@ fn reducer_outcome_response(
230341 outcome : ReducerOutcome ,
231342) -> ( StatusCode , Box < str > ) {
232343 match outcome {
233- ReducerOutcome :: Committed => ( StatusCode :: OK , "" . into ( ) ) ,
344+ ReducerOutcome :: Committed | ReducerOutcome :: Deduplicated => ( StatusCode :: OK , "" . into ( ) ) ,
234345 ReducerOutcome :: Failed ( errmsg) => {
235346 // TODO: different status code? this is what cloudflare uses, sorta
236347 ( StatusCode :: from_u16 ( 530 ) . unwrap ( ) , * errmsg)
@@ -1189,6 +1300,8 @@ pub struct DatabaseRoutes<S> {
11891300 pub subscribe_get : MethodRouter < S > ,
11901301 /// POST: /database/:name_or_identity/call/:reducer
11911302 pub call_reducer_procedure_post : MethodRouter < S > ,
1303+ /// POST: /database/:name_or_identity/call-from-database/:reducer?sender_identity=<hex>&tx_offset=<u64>
1304+ pub call_from_database_post : MethodRouter < S > ,
11921305 /// GET: /database/:name_or_identity/schema
11931306 pub schema_get : MethodRouter < S > ,
11941307 /// GET: /database/:name_or_identity/logs
@@ -1220,6 +1333,7 @@ where
12201333 identity_get : get ( get_identity :: < S > ) ,
12211334 subscribe_get : get ( handle_websocket :: < S > ) ,
12221335 call_reducer_procedure_post : post ( call :: < S > ) ,
1336+ call_from_database_post : post ( call_from_database :: < S > ) ,
12231337 schema_get : get ( schema :: < S > ) ,
12241338 logs_get : get ( logs :: < S > ) ,
12251339 sql_post : post ( sql :: < S > ) ,
@@ -1245,6 +1359,7 @@ where
12451359 . route ( "/identity" , self . identity_get )
12461360 . route ( "/subscribe" , self . subscribe_get )
12471361 . route ( "/call/:reducer" , self . call_reducer_procedure_post )
1362+ . route ( "/call-from-database/:reducer" , self . call_from_database_post )
12481363 . route ( "/schema" , self . schema_get )
12491364 . route ( "/logs" , self . logs_get )
12501365 . route ( "/sql" , self . sql_post )
0 commit comments