fusillade_core/request/query.rs
1//! Cross-batch request query types and filters.
2//!
3//! These types support listing and retrieving individual requests across batches,
4//! with server-side filtering, pagination, and sorting.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// Default number of rows to return when limit is not specified.
11const DEFAULT_LIMIT: i64 = 50;
12
13/// Derive the service tier from the batch completion window.
14/// "1h" → "flex" (async), everything else → NULL (batch).
15pub fn service_tier_from_completion_window(completion_window: &str) -> Option<&'static str> {
16 match completion_window {
17 "1h" => Some("flex"),
18 "0s" => Some("priority"),
19 _ => None,
20 }
21}
22
23/// Filter on `service_tier`.
24///
25/// `None` in the inner vec represents the batch tier (`service_tier IS NULL`);
26/// named strings match specific tier values such as `"flex"` or `"priority"`.
27///
28/// `Default` is `Any`. Storage backends isolate the background tier from this
29/// default; callers must explicitly include `Some("background")` to expose it.
30#[derive(Debug, Clone, Default)]
31pub enum ServiceTierFilter {
32 /// No caller-supplied filter. Storage backends include ordinary named tiers
33 /// and the batch tier (NULL), but keep background isolated.
34 #[default]
35 Any,
36 /// Match only rows whose tier is in this set. Empty matches nothing.
37 Include(Vec<Option<String>>),
38 /// Match all tiers except those in this set.
39 Exclude(Vec<Option<String>>),
40}
41
42impl ServiceTierFilter {
43 /// Split a list of `Option<String>` tiers into (named_tiers, includes_null).
44 pub fn split(tiers: &[Option<String>]) -> (Vec<String>, bool) {
45 let mut names = Vec::with_capacity(tiers.len());
46 let mut has_null = false;
47 for t in tiers {
48 match t {
49 Some(s) => names.push(s.clone()),
50 None => has_null = true,
51 }
52 }
53 (names, has_null)
54 }
55}
56
57/// Filter parameters for listing requests across batches.
58#[derive(Debug, Clone)]
59pub struct ListRequestsFilter {
60 /// Filter by request creator (user ID or org ID)
61 pub created_by: Option<String>,
62 /// Filter by request state (pending, claimed, processing, completed, failed, canceled)
63 pub status: Option<String>,
64 /// Filter by model(s) — when multiple, matches any.
65 /// `None` disables model filtering. `Some(vec![])` matches no rows.
66 pub models: Option<Vec<String>>,
67 /// Only return requests created after this timestamp
68 pub created_after: Option<DateTime<Utc>>,
69 /// Only return requests created before this timestamp
70 pub created_before: Option<DateTime<Utc>>,
71 /// Filter by service tier(s). When set, only returns requests whose
72 /// `service_tier` matches one of the provided values (e.g., `["flex", "priority"]`).
73 /// Uses `= ANY($7)` which hits the composite `idx_requests_created_tier` index.
74 /// `None` disables tier filtering. `Some(vec![])` matches no rows.
75 pub service_tiers: Option<Vec<String>>,
76 /// Sort active requests (pending/claimed/processing) first
77 pub active_first: bool,
78 /// Number of rows to skip (offset pagination)
79 pub skip: i64,
80 /// Maximum number of rows to return (defaults to 50)
81 pub limit: i64,
82}
83
84impl Default for ListRequestsFilter {
85 fn default() -> Self {
86 Self {
87 created_by: None,
88 status: None,
89 models: None,
90 created_after: None,
91 created_before: None,
92 service_tiers: None,
93 active_first: false,
94 skip: 0,
95 limit: DEFAULT_LIMIT,
96 }
97 }
98}
99
100/// Summary of an individual request, suitable for list views.
101///
102/// **Row scope: batchless-only.** `list_requests` filters
103/// `WHERE r.created_by IS NOT NULL` so the planner can use the partial
104/// index `idx_requests_user_*_sort`. Batched rows are not returned here.
105/// For batched-row attribution, fetch the row's `batches.created_by`
106/// separately or use `RequestDetail` (which joins batches).
107///
108/// Note: This type does not include user email or token/cost metrics.
109/// Callers should enrich with data from their own tables (users, analytics).
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[cfg_attr(feature = "sqlx-postgres", derive(sqlx::FromRow))]
112pub struct RequestSummary {
113 pub id: Uuid,
114 pub batch_id: Option<Uuid>,
115 pub model: String,
116 #[cfg_attr(feature = "sqlx-postgres", sqlx(rename = "state"))]
117 pub status: String,
118 pub created_at: DateTime<Utc>,
119 pub completed_at: Option<DateTime<Utc>>,
120 pub failed_at: Option<DateTime<Utc>>,
121 pub duration_ms: Option<f64>,
122 pub response_status: Option<i16>,
123 pub service_tier: Option<String>,
124 /// Creator ID (user or org) for ownership checks and email lookup.
125 /// Always set: `list_requests` only returns rows where
126 /// `requests.created_by IS NOT NULL` (see struct-level docs).
127 pub created_by: String,
128}
129
130/// Internal row shape used previously when `list_requests` computed the total
131/// count via a `COUNT(*) OVER()` window function. Kept for backward
132/// compatibility with the public re-export introduced in v16.1.0; no longer
133/// constructed by this crate.
134#[allow(deprecated)]
135mod deprecated_types {
136 use super::{DateTime, Deserialize, RequestSummary, Serialize, Utc, Uuid};
137
138 #[deprecated(
139 since = "16.1.1",
140 note = "no longer used internally; use RequestSummary and RequestListResult.total_count"
141 )]
142 #[derive(Debug, Clone, Serialize, Deserialize)]
143 #[cfg_attr(feature = "sqlx-postgres", derive(sqlx::FromRow))]
144 pub struct RequestSummaryWithCount {
145 pub id: Uuid,
146 pub batch_id: Uuid,
147 pub model: String,
148 #[cfg_attr(feature = "sqlx-postgres", sqlx(rename = "state"))]
149 pub status: String,
150 pub created_at: DateTime<Utc>,
151 pub completed_at: Option<DateTime<Utc>>,
152 pub failed_at: Option<DateTime<Utc>>,
153 pub duration_ms: Option<f64>,
154 pub response_status: Option<i16>,
155 pub service_tier: Option<String>,
156 pub batch_created_by: String,
157 pub total_count: i64,
158 }
159
160 impl From<RequestSummaryWithCount> for RequestSummary {
161 fn from(r: RequestSummaryWithCount) -> Self {
162 Self {
163 id: r.id,
164 batch_id: Some(r.batch_id),
165 model: r.model,
166 status: r.status,
167 created_at: r.created_at,
168 completed_at: r.completed_at,
169 failed_at: r.failed_at,
170 duration_ms: r.duration_ms,
171 response_status: r.response_status,
172 service_tier: r.service_tier,
173 created_by: r.batch_created_by,
174 }
175 }
176 }
177}
178
179#[allow(deprecated)]
180pub use deprecated_types::RequestSummaryWithCount;
181
182/// Full detail of an individual request, including body and response.
183///
184/// **Row scope: batchless-only.** `get_request_detail` filters
185/// `WHERE r.created_by IS NOT NULL`. Per-row inspection of batched
186/// requests goes through `get_batch_results_stream` / `BatchResultItem`
187/// instead. Used by the dashboard's response-detail page and the Open
188/// Responses API.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[cfg_attr(feature = "sqlx-postgres", derive(sqlx::FromRow))]
191pub struct RequestDetail {
192 pub id: Uuid,
193 /// Always `None` — this query is scoped to batchless rows only.
194 /// Per-row inspection of batched requests uses
195 /// `get_batch_results_stream` / `BatchResultItem`.
196 pub batch_id: Option<Uuid>,
197 pub model: String,
198 #[cfg_attr(feature = "sqlx-postgres", sqlx(rename = "state"))]
199 pub status: String,
200 pub created_at: DateTime<Utc>,
201 pub completed_at: Option<DateTime<Utc>>,
202 pub failed_at: Option<DateTime<Utc>>,
203 pub duration_ms: Option<f64>,
204 pub response_status: Option<i16>,
205 /// `None` when the template has been purged (file soft-deleted + orphan purge).
206 pub body: Option<String>,
207 pub response_body: Option<String>,
208 pub error: Option<String>,
209 pub service_tier: Option<String>,
210 /// Creator ID (user or org). Always set: `get_request_detail` only
211 /// returns rows where `requests.created_by IS NOT NULL`.
212 pub created_by: String,
213}
214
215/// Input for creating a realtime response that the proxy is already handling.
216///
217/// Inserts a request template (no parent file) and a request row in
218/// `processing` state with `batch_id = NULL` and `daemon_id = Uuid::nil()`.
219/// The proxy completes/fails the row directly via `complete_request` /
220/// `fail_request`; the daemon never claims it.
221#[derive(Debug, Clone)]
222pub struct CreateRealtimeInput {
223 /// Pre-generated request ID. Becomes the request's primary key.
224 pub request_id: Uuid,
225 /// The request body as a JSON string.
226 pub body: String,
227 /// Model identifier.
228 pub model: String,
229 /// Base URL of the target endpoint (e.g., "http://localhost:3001/ai").
230 pub endpoint: String,
231 /// HTTP method (e.g., "POST").
232 pub method: String,
233 /// API path (e.g., "/v1/responses").
234 pub path: String,
235 /// API key for the request.
236 pub api_key: String,
237 /// User/org ID that owns this request.
238 pub created_by: String,
239}
240
241/// Input for persisting a batch of already-completed realtime responses.
242///
243/// Used by the dwctl responses writer to flush a buffer of finished
244/// realtime calls in one transaction.
245///
246/// Two cases are handled in the same batch:
247/// * Background realtime: the row was pre-created in `processing` state by
248/// `create_realtime`; we UPDATE it to `completed`.
249/// * Non-background realtime: no row exists yet; we INSERT a template and
250/// a request row directly in `completed` state.
251///
252/// All synthesize fields (`request_body`, `model`, `endpoint`, `started_at`,
253/// `completed_at`, etc.) are only consulted on the INSERT path. On the UPDATE
254/// path only `request_id`, `response_body`, and `status_code` are used — the
255/// pre-existing row already carries a real `started_at` from `create_realtime`.
256#[derive(Debug, Clone)]
257pub struct PersistCompletedRealtimeInput {
258 /// The request UUID (primary key).
259 pub request_id: Uuid,
260 /// Upstream response body to store.
261 pub response_body: String,
262 /// Upstream HTTP status code.
263 pub status_code: u16,
264 /// Original request body, stored on the synthesized template.
265 pub request_body: String,
266 /// Model identifier.
267 pub model: String,
268 /// Base URL of the target endpoint.
269 pub endpoint: String,
270 /// HTTP method (e.g., "POST").
271 pub method: String,
272 /// API path (e.g., "/v1/responses").
273 pub path: String,
274 /// API key for the request.
275 pub api_key: String,
276 /// User/org ID that owns this request.
277 pub created_by: String,
278 /// Wall-clock instant the request arrived, as recorded by the caller.
279 /// INSERT path only: becomes the synthesized row's `created_at`,
280 /// `claimed_at`, and `started_at`. Ignored on the UPDATE path, where the row
281 /// already carries a real `started_at`.
282 pub started_at: DateTime<Utc>,
283 /// Wall-clock instant the response completed (`started_at` plus the caller's
284 /// measured request duration). INSERT path only: stored as the row's `completed_at` on 2xx
285 /// (so the listing's `duration_ms = completed_at - started_at` reflects the
286 /// true latency instead of zero) or as `failed_at` on non-2xx. Note
287 /// `duration_ms` is derived from the `completed_at` column, so it is NULL for
288 /// failed rows — the completion instant is still recorded there, in
289 /// `failed_at`. Ignored on the UPDATE path.
290 pub completed_at: DateTime<Utc>,
291}
292
293/// Input for creating a flex (async) response that the daemon will process.
294///
295/// Inserts a request template (no parent file) and a request row in `pending`
296/// state with `batch_id = NULL`. The daemon claims and processes it like any
297/// other pending request.
298#[derive(Debug, Clone)]
299pub struct CreateFlexInput {
300 /// Pre-generated request ID. Becomes the request's primary key.
301 pub request_id: Uuid,
302 /// The request body as a JSON string.
303 pub body: String,
304 /// Model identifier.
305 pub model: String,
306 /// Base URL of the target endpoint (e.g., "http://localhost:3001/ai").
307 pub endpoint: String,
308 /// HTTP method (e.g., "POST").
309 pub method: String,
310 /// API path (e.g., "/v1/responses").
311 pub path: String,
312 /// API key for the request.
313 pub api_key: String,
314 /// User/org ID that owns this request.
315 pub created_by: String,
316 /// Provenance for the dispatched request, stored on the template.
317 ///
318 /// The batchless equivalent of `BatchInput::metadata`: keys named in the
319 /// daemon's `batch_metadata_fields` are replayed onto the dispatched request
320 /// as `x-fusillade-batch-<key>` headers. A flex request is dispatched long
321 /// after the caller submitted it, over a client that sends no User-Agent, so
322 /// this is the only way anything about the submitter reaches the far side.
323 pub metadata: Option<serde_json::Value>,
324}
325
326/// Input for creating a background response that the daemon will process only
327/// when the target model has spare live capacity.
328///
329/// Inserts a request template (no parent file) and a request row in `pending`
330/// state with `batch_id = NULL` and `service_tier = 'background'`.
331#[derive(Debug, Clone)]
332pub struct CreateBackgroundInput {
333 /// Pre-generated request ID. Becomes the request's primary key.
334 pub request_id: Uuid,
335 /// The request body as a JSON string.
336 pub body: String,
337 /// Model identifier.
338 pub model: String,
339 /// Base URL of the target endpoint (e.g., "http://localhost:3001/ai").
340 pub endpoint: String,
341 /// HTTP method (e.g., "POST").
342 pub method: String,
343 /// API path (e.g., "/v1/responses").
344 pub path: String,
345 /// API key for the request.
346 pub api_key: String,
347 /// User/org ID that owns this request.
348 pub created_by: String,
349 /// Provenance for the dispatched request, stored on the template. See
350 /// [`CreateFlexInput::metadata`].
351 pub metadata: Option<serde_json::Value>,
352}
353
354/// Result of a paginated request list query.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct RequestListResult {
357 pub data: Vec<RequestSummary>,
358 /// Best-effort total row count for the full query result.
359 ///
360 /// Returns an exact count when the count query completes within a short
361 /// internal timeout; otherwise falls back to a query-planner row estimate.
362 /// Planner estimates are typically within a few percent when table
363 /// statistics are current, but may diverge more if stats are stale.
364 pub total_count: i64,
365}
366
367#[cfg(test)]
368mod background_tests {
369 use super::*;
370
371 #[test]
372 fn background_input_has_the_async_batchless_contract() {
373 let request_id = Uuid::new_v4();
374 let input = CreateBackgroundInput {
375 request_id,
376 body: r#"{"model":"model-a"}"#.to_string(),
377 model: "model-a".to_string(),
378 endpoint: "http://localhost:3001".to_string(),
379 method: "POST".to_string(),
380 path: "/v1/responses".to_string(),
381 api_key: "test-key".to_string(),
382 created_by: "user-a".to_string(),
383 metadata: None,
384 };
385
386 assert_eq!(input.request_id, request_id);
387 assert_eq!(input.model, "model-a");
388 assert_eq!(input.created_by, "user-a");
389 }
390}