feat(sidecar): store and serve the player-vendor market index
Protocol 3.0 §8. Ingests vendor.listing / vendor.listing.remove into a `vendors` table and serves GET /market. The frame is authoritative for one vendor, so the upsert is a whole-row overwrite. Unlike the other 3.0 boards there IS a remove: a vendor is dismissed, expires, or its owner switches off the in-game Vendor Search flag — the last of those is a privacy control, so dropping the row promptly is the point. Items ride inside the stored blob and are deliberately not normalized into a vendor_items table. The sidecar's job for the market is outage resilience (PROTOCOL_2.md §12.2), not search; search lives in MariaDB on the website side, where the query surface, the indexes and the cliloc-resolved names already are. /market is the only PAGED read the sidecar serves, because it is the only board that can be a whole world's inventory. limit clamps to 1..1000 (default 200) and `total` comes back so a caller knows when to stop rather than paging until it sees a short page, which would race a concurrent sweep. Ordering is by SERIAL, not shop name: a serial is stable while a shop name is renameable, so a rename mid-walk cannot make a vendor skip or repeat a page. The route is /market and not /vendors: /vendors/:account next door is the per-account RPC, and two routes a prefix apart meaning "this player's shops" and "every shop on the shard" is a readability trap. Frames are served verbatim, owner names and coordinates included — the sidecar defines no audiences (v3.md §3.2). Verified against the live shard: 27 vendors / 1,040 listings ingested from the plugin, plus a synthetic insert-then-remove confirming the delete path. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
// whole set, or one system by its PointsType name.
|
||||
.route("/points", get(points))
|
||||
.route("/points/:system", get(points_system))
|
||||
// The player-vendor market index (Protocol 3.0). `/market`, NOT `/vendors`: axum would
|
||||
// route the latter fine, but `/vendors/:account` next door is the per-account RPC, and two
|
||||
// routes a prefix apart that mean "this player's shops" and "every shop on the shard" is a
|
||||
// readability trap nobody wins. The only PAGED read the sidecar serves — a whole-world
|
||||
// market does not fit in one response.
|
||||
.route("/market", get(market))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||
|
||||
let app = Router::new()
|
||||
@@ -872,6 +878,54 @@ async fn online(State(st): State<AppState>) -> impl IntoResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PageQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// The player-vendor market index: every vendor's shop name, owner, location and priced inventory,
|
||||
/// as the shard last published it. Store-backed like the other boards, which is what lets the
|
||||
/// website's market page render (labelled stale) while the shard is down.
|
||||
///
|
||||
/// Paged — `?limit=&offset=`, limit clamped to 1..1000, default 200 — because this is the one board
|
||||
/// that can be a whole world's inventory. `total` is returned alongside so the caller knows when to
|
||||
/// stop rather than paging until it sees a short page, which would race a concurrent sweep.
|
||||
///
|
||||
/// The frames are served VERBATIM, including owner names and coordinates. That is not an oversight:
|
||||
/// the sidecar defines no audiences (docs/link/v3.md §3.2). Deciding who may see a vendor's owner
|
||||
/// or whereabouts is the website's job and is admin-configurable there.
|
||||
async fn market(State(st): State<AppState>, Query(q): Query<PageQuery>) -> impl IntoResponse {
|
||||
let limit = q.limit.unwrap_or(200);
|
||||
let offset = q.offset.unwrap_or(0);
|
||||
|
||||
let total = match st.store.vendors_count().await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
match st.store.vendors_page(limit, offset).await {
|
||||
Ok(vendors) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"vendors": vendors,
|
||||
"total": total,
|
||||
"limit": limit.clamp(1, 1000),
|
||||
"offset": offset.max(0),
|
||||
})),
|
||||
),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- websocket ----
|
||||
|
||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
|
||||
Reference in New Issue
Block a user