feat(consume): durable per-instance contract state + HTTP apply route
CI / Build, lint & test (push) Successful in 3m16s
CI / Build, lint & test (push) Successful in 3m16s
`consume_item` was a complete, tested, atomic apply transaction with zero
production callers and no route -- it could not be reached over HTTP because
its effect is an in-process `ItemMutation` trait object and the host is a
separate process on a synchronous JSON boundary.
Closes that gap with a CLOSED, Core-validated effect vocabulary rather than a
pass-through: `InstanceEffect::AddContractMatches { amount, cap,
default_when_unset }`. A generic "apply this field/value" escape hatch would
hand economic authority back to the caller and break the architecture.
The read-modify-write runs INSIDE the caller's transaction
(`min(cap, COALESCE(contract_matches, default) + amount)`) so two concurrent
applies cannot lose an update, and the reported `granted` stays the requested
amount even when the cap clamps the total.
Migration 0028 adds `owned_cards.contract_matches` NULLABLE: NULL means "Core
tracks no contract here", which keeps the pack-fresh default (a FIFA-specific
7) out of Core and leaves every existing row unchanged in meaning. ADD COLUMN,
not a rebuild -- a rebuild would drop 0026's transfer trigger.
Two ordering fixes forced by putting this on the live path:
* consume_item moves from DEFERRED `pool.begin()` to `BEGIN IMMEDIATE`, the
discipline economy.rs documents: three reads precede the first write, which
is exactly the shape that returns SQLITE_BUSY past the busy handler.
* the replay answer now precedes source validation. With DestroyInstance the
first apply deletes the source, so the old order answered a retry with 404
instead of the recorded outcome -- replay semantics were unreachable.
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
//! The CLOSED vocabulary of instance mutations Core can execute for a caller
|
||||
//! that is not in-process.
|
||||
//!
|
||||
//! [`crate::services::consume::consume_item`] takes an
|
||||
//! [`ItemMutation`] — an in-process closure, which cannot cross an HTTP
|
||||
//! boundary. A game host applying a consumable over HTTP therefore cannot SUPPLY
|
||||
//! its effect; it can only DESCRIBE one, and Core executes the description.
|
||||
//!
|
||||
//! That does not move the game's formula into Core. The caller still owns every
|
||||
//! number: how many match-contracts a given card grants a given target is the
|
||||
//! adapter's reversed per-game table, and it arrives here as `amount`. Core owns
|
||||
//! only what it can prove without knowing the game — the arithmetic, the clamp,
|
||||
//! the ownership scope, the loan invariant and the transaction. This is the same
|
||||
//! split quick-sell already uses (the host prices the item, Core moves it).
|
||||
//!
|
||||
//! The enum is closed ON PURPOSE. A generic "set field X to value Y" escape
|
||||
//! hatch would hand the host arbitrary write access to Core state and make every
|
||||
//! present and future invariant unenforceable; a new effect is a new variant,
|
||||
//! validated here, reviewed here.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
models::card::OwnedCard,
|
||||
services::consume::{ConsumeContext, ItemMutation, MutationFuture},
|
||||
};
|
||||
|
||||
/// One wire-describable mutation of a target instance.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum InstanceEffect {
|
||||
/// Add match-contracts to the target, saturating at `cap`.
|
||||
///
|
||||
/// `default_when_unset` is what a target Core tracks no contract for counts
|
||||
/// as — the caller's pack-fresh starting value (FIFA 17: 7). Core has no such
|
||||
/// default of its own, which is precisely why `owned_cards.contract_matches`
|
||||
/// is nullable; see migration 0028.
|
||||
AddContractMatches {
|
||||
amount: i64,
|
||||
cap: i64,
|
||||
default_when_unset: i64,
|
||||
},
|
||||
}
|
||||
|
||||
impl ItemMutation for InstanceEffect {
|
||||
fn apply<'c>(
|
||||
&'c self,
|
||||
tx: &'c mut SqliteConnection,
|
||||
ctx: &'c ConsumeContext,
|
||||
) -> MutationFuture<'c> {
|
||||
match self {
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount,
|
||||
cap,
|
||||
default_when_unset,
|
||||
} => Box::pin(add_contract_matches(
|
||||
tx,
|
||||
ctx,
|
||||
*amount,
|
||||
*cap,
|
||||
*default_when_unset,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The target instance this effect mutates. An effect that writes to an instance
|
||||
/// has nothing to write to when the application is club-scoped, so that is a
|
||||
/// refusal rather than a silent no-op that still spends the source.
|
||||
fn require_target<'a>(ctx: &'a ConsumeContext, effect: &str) -> AppResult<&'a OwnedCard> {
|
||||
ctx.target
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::BadRequest(format!("effect '{effect}' requires a target item")))
|
||||
}
|
||||
|
||||
async fn add_contract_matches(
|
||||
tx: &mut SqliteConnection,
|
||||
ctx: &ConsumeContext,
|
||||
amount: i64,
|
||||
cap: i64,
|
||||
default_when_unset: i64,
|
||||
) -> AppResult<Value> {
|
||||
let target = require_target(ctx, "add_contract_matches")?;
|
||||
|
||||
if amount < 1 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"add_contract_matches amount must be >= 1, got {amount}"
|
||||
)));
|
||||
}
|
||||
if cap < 1 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"add_contract_matches cap must be >= 1, got {cap}"
|
||||
)));
|
||||
}
|
||||
if default_when_unset < 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"add_contract_matches default_when_unset must be >= 0, got {default_when_unset}"
|
||||
)));
|
||||
}
|
||||
// A loan item is borrowed for a fixed number of matches
|
||||
// (`loan_matches_remaining`); topping up its contract would pretend to extend
|
||||
// something Core does not own. Core models loans, so the invariant is Core's.
|
||||
if target.is_loan {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"contracts cannot be applied to a loan item ('{}')",
|
||||
target.id
|
||||
)));
|
||||
}
|
||||
|
||||
// Read-modify-write INSIDE the caller's transaction, deliberately re-reading
|
||||
// rather than trusting the snapshot in `ctx`: the read and the write then sit
|
||||
// in one contiguous critical section under the same write lock, so two
|
||||
// concurrent applies serialise instead of both computing from one `before`
|
||||
// and losing an update.
|
||||
let before = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COALESCE(contract_matches, ?) FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||
)
|
||||
.bind(default_when_unset)
|
||||
.bind(&target.id)
|
||||
.bind(&ctx.club_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("target item '{}' not found", target.id)))?;
|
||||
|
||||
// Saturating, because `before + amount` is caller-supplied arithmetic and an
|
||||
// overflow must not panic a request thread; the clamp makes the sum's exact
|
||||
// magnitude irrelevant anyway.
|
||||
let after = before.saturating_add(amount).min(cap);
|
||||
|
||||
let updated =
|
||||
sqlx::query("UPDATE owned_cards SET contract_matches = ? WHERE id = ? AND club_id = ?")
|
||||
.bind(after)
|
||||
.bind(&target.id)
|
||||
.bind(&ctx.club_id)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if updated != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"target item '{}' changed under us",
|
||||
target.id
|
||||
)));
|
||||
}
|
||||
|
||||
// `granted` is what the caller's table awarded, NOT `after - before`: the cap
|
||||
// can swallow part of it, and the two numbers answer different questions
|
||||
// (what the card was worth vs. what the instance now holds).
|
||||
Ok(json!({
|
||||
"kind": "add_contract_matches",
|
||||
"granted": amount,
|
||||
"before": before,
|
||||
"after": after,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
use crate::models::card::ContentKind;
|
||||
use crate::services::consume::{
|
||||
consume_item, ConsumeRequest, ConsumeTarget, SourceConsumption,
|
||||
};
|
||||
|
||||
const TS: &str = "2026-01-01T00:00:00Z";
|
||||
|
||||
/// One club holding contract consumables and three player targets: one Core
|
||||
/// tracks no contract for, one part-way through its contract, and one on loan.
|
||||
async fn fixture() -> (tempfile::TempDir, db::Pool) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let url = format!("sqlite://{}", dir.path().join("core.db").display());
|
||||
let pool = db::init_pool(&url, 5).await.expect("init pool");
|
||||
db::run_migrations(&pool).await.expect("migrations");
|
||||
|
||||
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?,?,?,?)")
|
||||
.bind("prof")
|
||||
.bind("prof")
|
||||
.bind(TS)
|
||||
.bind(TS)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("profile");
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
||||
VALUES ('club', 'prof', 'club', 0, ?, ?)",
|
||||
)
|
||||
.bind(TS)
|
||||
.bind(TS)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("club");
|
||||
|
||||
for (id, kind, is_loan, contract) in [
|
||||
("card-1", ContentKind::Consumable, 0, None),
|
||||
("card-2", ContentKind::Consumable, 0, None),
|
||||
("card-3", ContentKind::Consumable, 0, None),
|
||||
("fresh", ContentKind::Player, 0, None),
|
||||
("used", ContentKind::Player, 0, Some(90i64)),
|
||||
("loaned", ContentKind::Player, 1, None),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards \
|
||||
(id, club_id, card_id, is_loan, acquired_at, content_kind, contract_matches) \
|
||||
VALUES (?, 'club', ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(format!("def-{id}"))
|
||||
.bind(is_loan)
|
||||
.bind(TS)
|
||||
.bind(kind.as_str())
|
||||
.bind(contract)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("owned card");
|
||||
}
|
||||
(dir, pool)
|
||||
}
|
||||
|
||||
fn apply_request<'a>(
|
||||
identity: &'a str,
|
||||
source: &'a str,
|
||||
target: &'a str,
|
||||
) -> ConsumeRequest<'a> {
|
||||
ConsumeRequest {
|
||||
action_identity: identity,
|
||||
source_owned_card_id: source,
|
||||
expected_source_kind: ContentKind::Consumable,
|
||||
consumption: SourceConsumption::DestroyInstance,
|
||||
target: ConsumeTarget::OwnedCard {
|
||||
owned_card_id: target,
|
||||
expected_kind: ContentKind::Player,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn grant(amount: i64) -> InstanceEffect {
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount,
|
||||
cap: 99,
|
||||
default_when_unset: 7,
|
||||
}
|
||||
}
|
||||
|
||||
async fn contract_of(pool: &db::Pool, id: &str) -> Option<i64> {
|
||||
sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT contract_matches FROM owned_cards WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("read contract")
|
||||
}
|
||||
|
||||
/// A target Core tracks no contract for counts as the CALLER's pack-fresh
|
||||
/// default, not as zero — the whole reason the column is nullable.
|
||||
#[tokio::test]
|
||||
async fn an_unset_target_starts_from_the_callers_default() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let out = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-1", "card-1", "fresh"),
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect("apply");
|
||||
|
||||
assert!(out.applied);
|
||||
assert!(out.source_destroyed);
|
||||
assert_eq!(
|
||||
out.effect,
|
||||
json!({ "kind": "add_contract_matches", "granted": 15, "before": 7, "after": 22 })
|
||||
);
|
||||
assert_eq!(contract_of(&pool, "fresh").await, Some(22));
|
||||
}
|
||||
|
||||
/// A stored value is added to, never replaced by the default.
|
||||
#[tokio::test]
|
||||
async fn an_existing_value_is_added_to() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let out = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-2", "card-1", "used"),
|
||||
&grant(3),
|
||||
)
|
||||
.await
|
||||
.expect("apply");
|
||||
assert_eq!(
|
||||
out.effect,
|
||||
json!({ "kind": "add_contract_matches", "granted": 3, "before": 90, "after": 93 })
|
||||
);
|
||||
assert_eq!(contract_of(&pool, "used").await, Some(93));
|
||||
}
|
||||
|
||||
/// The cap clamps the STORED total but not the REPORTED grant: they answer
|
||||
/// different questions, and a caller reconciling its own wire response needs
|
||||
/// the amount its table awarded.
|
||||
#[tokio::test]
|
||||
async fn the_cap_clamps_the_total_but_not_the_reported_grant() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let out = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-3", "card-1", "used"),
|
||||
&grant(28),
|
||||
)
|
||||
.await
|
||||
.expect("apply");
|
||||
assert_eq!(
|
||||
out.effect,
|
||||
json!({ "kind": "add_contract_matches", "granted": 28, "before": 90, "after": 99 })
|
||||
);
|
||||
assert_eq!(contract_of(&pool, "used").await, Some(99));
|
||||
}
|
||||
|
||||
/// A loan is borrowed for a fixed run of matches; its contract is not Core's
|
||||
/// to extend. The refusal must also unwind the charge.
|
||||
#[tokio::test]
|
||||
async fn a_loan_target_is_refused_and_the_source_survives() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let err = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-loan", "card-1", "loaned"),
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect_err("a loan cannot take a contract");
|
||||
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
|
||||
assert_eq!(contract_of(&pool, "loaned").await, None);
|
||||
let sources =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = 'card-1'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sources, 1, "a refused effect must not spend the source");
|
||||
}
|
||||
|
||||
/// This effect writes to an instance, so a club-scoped application has
|
||||
/// nothing to write to — refuse rather than spend the source for nothing.
|
||||
#[tokio::test]
|
||||
async fn a_club_scoped_application_is_refused() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let err = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&ConsumeRequest {
|
||||
action_identity: "act-club",
|
||||
source_owned_card_id: "card-1",
|
||||
expected_source_kind: ContentKind::Consumable,
|
||||
consumption: SourceConsumption::DestroyInstance,
|
||||
target: ConsumeTarget::Club,
|
||||
},
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect_err("no target to contract");
|
||||
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
/// Core validates the caller's numbers instead of trusting them: a zero or
|
||||
/// negative grant, a zero cap and a negative default are all nonsense, and a
|
||||
/// nonsense application must not silently succeed as a no-op.
|
||||
#[tokio::test]
|
||||
async fn nonsensical_parameters_are_refused() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
for (identity, effect) in [
|
||||
(
|
||||
"bad-amount-0",
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: 0,
|
||||
cap: 99,
|
||||
default_when_unset: 7,
|
||||
},
|
||||
),
|
||||
(
|
||||
"bad-amount-neg",
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: -5,
|
||||
cap: 99,
|
||||
default_when_unset: 7,
|
||||
},
|
||||
),
|
||||
(
|
||||
"bad-cap",
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: 1,
|
||||
cap: 0,
|
||||
default_when_unset: 7,
|
||||
},
|
||||
),
|
||||
(
|
||||
"bad-default",
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: 1,
|
||||
cap: 99,
|
||||
default_when_unset: -1,
|
||||
},
|
||||
),
|
||||
] {
|
||||
let Err(err) = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request(identity, "card-1", "fresh"),
|
||||
&effect,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("{identity} must be refused");
|
||||
};
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(_)),
|
||||
"{identity}: got {err:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
contract_of(&pool, "fresh").await,
|
||||
None,
|
||||
"a refused application leaves the target untracked"
|
||||
);
|
||||
}
|
||||
|
||||
/// The replay guard covers the effect too: a repeated `action_identity`
|
||||
/// echoes the recorded outcome, adds no second grant, and spends no second
|
||||
/// card.
|
||||
#[tokio::test]
|
||||
async fn a_replay_neither_grants_nor_charges_twice() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let first = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-replay", "card-1", "fresh"),
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect("first");
|
||||
assert!(first.applied);
|
||||
|
||||
let replay = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-replay", "card-1", "fresh"),
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect("replay");
|
||||
assert!(!replay.applied, "a replay must not re-apply");
|
||||
assert_eq!(
|
||||
replay.effect, first.effect,
|
||||
"the recorded outcome is echoed"
|
||||
);
|
||||
assert_eq!(contract_of(&pool, "fresh").await, Some(22), "granted once");
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id LIKE 'card-%'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
2,
|
||||
"exactly one consumable was spent"
|
||||
);
|
||||
}
|
||||
|
||||
/// The wire vocabulary is closed: the documented body parses, and an
|
||||
/// undescribed effect is rejected at the boundary rather than reaching a
|
||||
/// fallback.
|
||||
#[test]
|
||||
fn the_effect_vocabulary_is_closed() {
|
||||
let parsed: InstanceEffect = serde_json::from_str(
|
||||
r#"{"kind":"add_contract_matches","amount":15,"cap":99,"default_when_unset":7}"#,
|
||||
)
|
||||
.expect("the documented effect body must parse");
|
||||
assert!(matches!(
|
||||
parsed,
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: 15,
|
||||
cap: 99,
|
||||
default_when_unset: 7
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
serde_json::from_str::<InstanceEffect>(r#"{"kind":"set_rating","value":99}"#).is_err(),
|
||||
"an effect Core does not implement must be refused, never guessed"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user