Skip to content

Commit a68eba6

Browse files
authored
Merge pull request #21 from CurtisTechSolutions/feat/match-history
feat: add match metadata and paginated history
2 parents 4ce7184 + e66bbd3 commit a68eba6

12 files changed

Lines changed: 376 additions & 3 deletions

File tree

‎.sqlx/query-519b6a4759c289716712431f0ca57851ff443ee5fe4fcd6c64f60d606c2cb13d.json‎

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/adapters/gfa-http/src/lib.rs‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ fn build_router(
8787
"/v1/games/{game_id}/positions/validate",
8888
post(validate_position),
8989
)
90-
.route("/v1/matches", post(create))
90+
.route("/v1/matches", post(create).get(history))
91+
.route("/v1/matches/{id}", get(metadata))
9192
.route("/v1/matches/{id}/info", get(info::match_info))
9293
.route("/v1/matches/{id}/state", get(state))
9394
.route("/v1/matches/{id}/legal-actions", get(legal_actions))
@@ -327,3 +328,30 @@ async fn validate_position(
327328
viewer,
328329
)?))
329330
}
331+
332+
#[utoipa::path(
333+
get, path = "/v1/matches", tag = "Matches",
334+
params(gfa_api_types::MatchHistoryQuery),
335+
responses((status = 200, description = "Bounded local history, ordered by match id. Follow next even when filters return an empty page.", body = gfa_api_types::MatchHistory),
336+
(status = "default", description = "Query or service error", body = gfa_api_types::ErrorResponse))
337+
)]
338+
async fn history(
339+
State(service): State<Arc<GameService>>,
340+
query: Result<Query<gfa_api_types::MatchHistoryQuery>, QueryRejection>,
341+
) -> Result<Json<gfa_api_types::MatchHistory>, HttpError> {
342+
let Query(query) = query.map_err(|error| HttpError::request(error.body_text()))?;
343+
Ok(Json(service.list_matches(query).await?))
344+
}
345+
346+
#[utoipa::path(
347+
get, path = "/v1/matches/{id}", tag = "Matches",
348+
params(("id" = String, Path, description = "Match identifier")),
349+
responses((status = 200, description = "Public match metadata without hidden state or RNG", body = gfa_api_types::MatchMetadata),
350+
(status = "default", description = "Match or service error", body = gfa_api_types::ErrorResponse))
351+
)]
352+
async fn metadata(
353+
State(service): State<Arc<GameService>>,
354+
path: Id,
355+
) -> Result<Json<gfa_api_types::MatchMetadata>, HttpError> {
356+
Ok(Json(service.get_match(&id(path)?).await?))
357+
}

‎crates/adapters/gfa-http/src/openapi.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use utoipa::OpenApi;
66
info(title = "GamesForAI local API", description = "Single-user local play. Requests require a loopback peer and matching Host and optional Origin. Any local caller can control either seat. Bodies are limited to 64 KiB. Only implemented endpoints are listed."),
77
servers((url = "/", description = "This local host")),
88
paths(
9-
super::health, super::games, super::game, super::create, super::state,
9+
super::history, super::metadata, super::health, super::games, super::game, super::create, super::state,
1010
super::legal_actions, super::make_move, super::replay, super::validate_position,
1111
super::info::game_info, super::info::match_info, super::stream::upgrade, document
1212
),

‎crates/adapters/gfa-store/src/lib.rs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,23 @@ impl MatchStore for SqliteMatchStore {
207207
Box::pin(self.snapshot(id))
208208
}
209209

210+
fn list_ids<'a>(&'a self, after: &'a str, limit: u32) -> StoreFuture<'a, Vec<String>> {
211+
Box::pin(async move {
212+
if !(1..=101).contains(&limit) {
213+
return Err(unavailable("History limit must be 1..101"));
214+
}
215+
let limit = i64::from(limit);
216+
sqlx::query_scalar!(
217+
"SELECT id FROM gfa_matches WHERE id > ? ORDER BY id LIMIT ?",
218+
after,
219+
limit
220+
)
221+
.fetch_all(&self.pool)
222+
.await
223+
.map_err(unavailable)
224+
})
225+
}
226+
210227
fn append<'a>(
211228
&'a self,
212229
id: &'a str,

‎crates/adapters/gfa-store/src/tests.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,19 @@ async fn corrupt_event_sequences_fail_instead_of_returning_partial_history() ->
285285
store.close().await;
286286
Ok(())
287287
}
288+
289+
#[tokio::test]
290+
async fn history_cursor_is_ordered_bounded_and_parameterized() -> TestResult {
291+
let store = SqliteMatchStore::in_memory().await?;
292+
for id in ["c", "a", "b"] {
293+
store.create(new_record(id)).await?;
294+
}
295+
assert_eq!(store.list_ids("", 2).await?, vec!["a", "b"]);
296+
assert_eq!(store.list_ids("b", 2).await?, vec!["c"]);
297+
assert!(store.list_ids("c", 2).await?.is_empty());
298+
assert_eq!(store.list_ids("' OR 1=1 --", 101).await?.len(), 3);
299+
assert!(store.list_ids("", 0).await.is_err());
300+
assert!(store.list_ids("", 102).await.is_err());
301+
store.close().await;
302+
Ok(())
303+
}

‎crates/apps/gfa-server/src/openapi_tests.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ async fn served_openapi_matches_real_requests_responses_and_conditional_formats(
5959
("/v1/games/{game_id}", "get"),
6060
("/v1/games/{game_id}/info", "get"),
6161
("/v1/matches", "post"),
62+
("/v1/matches", "get"),
63+
("/v1/matches/{id}", "get"),
6264
("/v1/matches/{id}/info", "get"),
6365
("/v1/matches/{id}/state", "get"),
6466
("/v1/matches/{id}/actions", "post"),
@@ -73,7 +75,7 @@ async fn served_openapi_matches_real_requests_responses_and_conditional_formats(
7375
"missing {method} {path}"
7476
);
7577
}
76-
assert_eq!(doc["paths"].as_object().map(serde_json::Map::len), Some(13));
78+
assert_eq!(doc["paths"].as_object().map(serde_json::Map::len), Some(14));
7779
let game_info = &doc["paths"]["/v1/games/{game_id}/info"]["get"];
7880
assert!(game_info["responses"]["200"]["content"]["text/markdown"].is_object());
7981
assert!(game_info["responses"]["304"].get("content").is_none());
@@ -125,6 +127,17 @@ async fn served_openapi_matches_real_requests_responses_and_conditional_formats(
125127
&initial
126128
));
127129
let id = initial["match_id"].as_str().ok_or("id")?;
130+
for (path, template) in [
131+
("/v1/matches".to_owned(), "/v1/matches"),
132+
(format!("/v1/matches/{id}"), "/v1/matches/{id}"),
133+
] {
134+
let (_, value) = call(&app.router, "GET", &path, Value::Null, None).await?;
135+
assert!(valid(
136+
&doc,
137+
response_schema(&doc, template, "get", "200"),
138+
&value
139+
));
140+
}
128141
let (_, moved) = call(
129142
&app.router,
130143
"POST",

‎crates/apps/gfa-server/src/tests.rs‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,102 @@ async fn position_validation_accepts_finished_boards_and_round_trips_all_encodin
364364
app.store.close().await;
365365
Ok(())
366366
}
367+
368+
#[tokio::test]
369+
async fn metadata_and_history_paginate_filter_and_survive_restart() -> TestResult {
370+
let dir = tempfile::tempdir()?;
371+
let settings = config(&dir);
372+
let app = fixture(&settings).await?;
373+
let mut ids = Vec::new();
374+
for game_id in ["tictactoe", "connect4", "tictactoe"] {
375+
let (_, created) = call(
376+
&app.router,
377+
"POST",
378+
"/v1/matches",
379+
json!({"game_id":game_id,"seed":42}),
380+
None,
381+
)
382+
.await?;
383+
ids.push(match_id(&created)?);
384+
}
385+
for (turn, action) in ["r1c1", "r2c1", "r1c2", "r2c2", "r1c3"].iter().enumerate() {
386+
call(
387+
&app.router,
388+
"POST",
389+
&format!("/v1/matches/{}/actions", ids[0]),
390+
json!({"seat":turn%2,"turn":turn,"action":action}),
391+
None,
392+
)
393+
.await?;
394+
}
395+
let (status, meta) = call(
396+
&app.router,
397+
"GET",
398+
&format!("/v1/matches/{}", ids[0]),
399+
Value::Null,
400+
None,
401+
)
402+
.await?;
403+
assert_eq!(status, StatusCode::OK);
404+
assert_eq!(meta["status"], "finished");
405+
assert_eq!(meta["turn"], 5);
406+
for private in ["seed", "start", "state", "reasoning"] {
407+
assert!(meta.get(private).is_none());
408+
}
409+
app.store.close().await;
410+
let app = fixture(&settings).await?;
411+
let mut cursor = String::new();
412+
let mut found = Vec::new();
413+
loop {
414+
let (_, page) = call(
415+
&app.router,
416+
"GET",
417+
&format!("/v1/matches?limit=1{cursor}"),
418+
Value::Null,
419+
None,
420+
)
421+
.await?;
422+
let records = page["matches"].as_array().ok_or("records")?;
423+
assert_eq!(records.len(), 1);
424+
found.push(records[0]["match_id"].as_str().ok_or("id")?.to_owned());
425+
match page["next"].as_str() {
426+
Some(next) => cursor = format!("&after={next}"),
427+
None => break,
428+
}
429+
assert!(found.len() <= 3);
430+
}
431+
ids.sort();
432+
assert_eq!(found, ids);
433+
let (_, finished) = call(
434+
&app.router,
435+
"GET",
436+
"/v1/matches?game_id=tictactoe&status=finished",
437+
Value::Null,
438+
None,
439+
)
440+
.await?;
441+
assert_eq!(finished["matches"].as_array().ok_or("matches")?.len(), 1);
442+
let (_, active) = call(
443+
&app.router,
444+
"GET",
445+
"/v1/matches?status=active",
446+
Value::Null,
447+
None,
448+
)
449+
.await?;
450+
assert_eq!(active["matches"].as_array().ok_or("matches")?.len(), 2);
451+
for path in [
452+
"/v1/matches?limit=0",
453+
"/v1/matches?limit=101",
454+
"/v1/matches?status=unknown",
455+
"/v1/matches?typo=1",
456+
"/v1/matches?limit=1&limit=2",
457+
"/v1/matches?game_id=missing",
458+
] {
459+
let (status, error) = call(&app.router, "GET", path, Value::Null, None).await?;
460+
assert!(status.is_client_error(), "{path}");
461+
assert!(error["error"]["code"].is_string());
462+
}
463+
app.store.close().await;
464+
Ok(())
465+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//! Public match metadata and bounded history queries.
2+
use schemars::JsonSchema;
3+
use serde::{Deserialize, Serialize};
4+
5+
/// Current lifecycle status derived from the event log.
6+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
7+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8+
#[serde(rename_all = "snake_case")]
9+
pub enum MatchStatus {
10+
/// The engine is waiting for another move.
11+
Active,
12+
/// The game reached a terminal result or a length cap.
13+
Finished,
14+
}
15+
16+
/// Public metadata, excluding private state, seeds and player reasoning.
17+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
19+
pub struct MatchMetadata {
20+
/// Match identifier.
21+
pub match_id: String,
22+
/// Registered game identifier.
23+
pub game_id: String,
24+
/// Engine version used for reconstruction.
25+
pub engine_version: String,
26+
/// Current lifecycle status.
27+
pub status: MatchStatus,
28+
/// Accepted action count.
29+
pub turn: u64,
30+
/// Seats currently required to act.
31+
pub to_act: Vec<u8>,
32+
/// Per-seat returns.
33+
pub returns: Vec<f64>,
34+
/// Rules ended play.
35+
pub terminated: bool,
36+
/// A length or external cap ended play.
37+
pub truncated: bool,
38+
/// Creation timestamp in Unix milliseconds.
39+
pub created_at_ms: u64,
40+
}
41+
42+
fn default_limit() -> u32 {
43+
50
44+
}
45+
46+
/// Cursor query for local match history.
47+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema, utoipa::IntoParams))]
48+
#[cfg_attr(feature = "openapi", into_params(parameter_in = Query))]
49+
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
50+
#[serde(deny_unknown_fields)]
51+
pub struct MatchHistoryQuery {
52+
/// Exclusive match-id cursor; omit for the first page.
53+
pub after: Option<String>,
54+
/// Restrict results to one registered game.
55+
pub game_id: Option<String>,
56+
/// Restrict results to active or finished games.
57+
pub status: Option<MatchStatus>,
58+
/// Maximum records scanned before filtering, from 1 to 100.
59+
#[serde(default = "default_limit")]
60+
pub limit: u32,
61+
}
62+
63+
impl Default for MatchHistoryQuery {
64+
fn default() -> Self {
65+
Self {
66+
after: None,
67+
game_id: None,
68+
status: None,
69+
limit: default_limit(),
70+
}
71+
}
72+
}
73+
74+
/// One bounded page; filters can produce an empty page with a continuation cursor.
75+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
76+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
77+
pub struct MatchHistory {
78+
/// Matching records from this page, in ascending match-id order.
79+
pub matches: Vec<MatchMetadata>,
80+
/// Exclusive cursor for the next page; null means the scan is exhausted.
81+
pub next: Option<String>,
82+
}

‎crates/domain/gfa-api-types/src/lib.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,3 +252,6 @@ pub enum StreamMessage {
252252
error: ApiError,
253253
},
254254
}
255+
256+
mod history;
257+
pub use history::{MatchHistory, MatchHistoryQuery, MatchMetadata, MatchStatus};

‎crates/domain/gfa-service/src/ports.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ pub trait MatchStore: Send + Sync {
3232
/// Read one consistent event/command snapshot.
3333
fn load<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<MatchRecord>>;
3434

35+
/// List up to limit identifiers in ascending order, strictly after the cursor.
36+
///
37+
/// Limit is 1..101. Hosts must authorize access to this local history index.
38+
fn list_ids<'a>(&'a self, _after: &'a str, _limit: u32) -> StoreFuture<'a, Vec<String>> {
39+
Box::pin(async {
40+
Err(StoreError::Unavailable(
41+
"History is unsupported by this store".into(),
42+
))
43+
})
44+
}
45+
3546
/// Compare-and-append using the current event count as the revision.
3647
///
3748
/// Check idempotency before the revision: an identical command returns its

0 commit comments

Comments
 (0)