feat(consume): durable per-instance contract state + HTTP apply route
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:
funman300
2026-08-22 18:23:05 +00:00
parent 233df1d99d
commit e8be289660
12 changed files with 1019 additions and 172 deletions
+31 -4
View File
@@ -1,12 +1,18 @@
//! Owned-content model migrations (0025 content_kind/quantity, 0026
//! club_active_items, 0027 consumable_applications).
//! club_active_items, 0027 consumable_applications, 0028 contract_matches).
//!
//! Two things must hold on a DB that already contains real ownership:
//! * every pre-existing owned row survives and reads back as a `player` with no
//! stack size (the migration is a pure widening, not a rewrite);
//! stack size and no tracked contract (the band is a pure widening, never a
//! rewrite and never a backfill of someone else's default);
//! * every existing kit designation lands in `club_active_items` under its
//! generalised slot token, and the old table + trigger are gone.
//!
//! 0028 additionally must NOT be a table rebuild: `owned_cards` carries 0026's
//! `clear_club_active_item_before_transfer` trigger, and a DROP/recreate would
//! take it along silently. The trigger assertions below therefore run AFTER the
//! whole band, not just after 0026.
//!
//! The first is proved against a COPY of a real populated club snapshot (1986
//! owned rows) when `OPENFUT_CORE_SNAPSHOT_DB` points at one; the second is
//! proved by staging a DB at migration 0025, writing 0024-era kit rows, and then
@@ -205,6 +211,25 @@ async fn kit_assignments_migrate_into_club_active_items() {
assert_eq!(kind, ContentKind::Player);
assert_eq!(quantity, None);
// 0028: the column exists and every row that pre-dates it reads back NULL.
// NULL is not zero — it means Core tracks no contract for the instance, so a
// backfill here would have invented one game's pack-fresh number for all of
// them.
let contract = sqlx::query_scalar::<_, Option<i64>>(
"SELECT contract_matches FROM owned_cards WHERE id = 'spare'",
)
.fetch_one(&pool)
.await
.expect("0028 must have added contract_matches");
assert_eq!(contract, None, "a pre-existing row tracks no contract");
assert!(
sqlx::query("UPDATE owned_cards SET contract_matches = -1 WHERE id = 'spare'")
.execute(&pool)
.await
.is_err(),
"contract_matches CHECK must reject a negative count"
);
// And the new column constraints are real, not documentation.
assert!(
sqlx::query("UPDATE owned_cards SET content_kind = 'coach' WHERE id = 'spare'")
@@ -300,10 +325,11 @@ async fn migrations_apply_to_a_real_populated_snapshot() {
.await
.expect("migrations must apply to real populated data");
let (after, players, stacked) = sqlx::query_as::<_, (i64, i64, i64)>(
let (after, players, stacked, contracted) = sqlx::query_as::<_, (i64, i64, i64, i64)>(
"SELECT COUNT(*), \
SUM(CASE WHEN content_kind = 'player' THEN 1 ELSE 0 END), \
SUM(CASE WHEN quantity IS NOT NULL THEN 1 ELSE 0 END) \
SUM(CASE WHEN quantity IS NOT NULL THEN 1 ELSE 0 END), \
SUM(CASE WHEN contract_matches IS NOT NULL THEN 1 ELSE 0 END) \
FROM owned_cards",
)
.fetch_one(&pool)
@@ -312,6 +338,7 @@ async fn migrations_apply_to_a_real_populated_snapshot() {
assert_eq!(after, before, "no owned row may be lost or duplicated");
assert_eq!(players, before, "every backfilled row is a player");
assert_eq!(stacked, 0, "no pre-existing row gains a stack size");
assert_eq!(contracted, 0, "no pre-existing row gains a contract count");
assert!(table_exists(&pool, "club_active_items").await);
assert!(!table_exists(&pool, "club_kit_assignments").await);