e8be289660
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.
366 lines
13 KiB
Rust
366 lines
13 KiB
Rust
//! Owned-content model migrations (0025 content_kind/quantity, 0026
|
|
//! 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 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
|
|
//! letting the remaining migrations run.
|
|
|
|
use std::borrow::Cow;
|
|
|
|
use openfut_core::models::card::{ActiveSlot, ContentKind};
|
|
use sqlx::migrate::Migrator;
|
|
use sqlx::sqlite::SqlitePoolOptions;
|
|
use sqlx::{Row, SqlitePool};
|
|
|
|
const OWNED_CONTENT_MIGRATION: i64 = 25;
|
|
|
|
async fn pool_for(path: &std::path::Path) -> SqlitePool {
|
|
let opts = sqlx::sqlite::SqliteConnectOptions::new()
|
|
.filename(path)
|
|
.create_if_missing(true)
|
|
.foreign_keys(true);
|
|
SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect_with(opts)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("open {}: {e}", path.display()))
|
|
}
|
|
|
|
/// The full migrator, truncated after `version`. Used to stage a DB in the state
|
|
/// it had BEFORE the migrations under test, so their data carry-over is exercised
|
|
/// on rows that really pre-date them.
|
|
fn migrator_upto(version: i64) -> Migrator {
|
|
let full = sqlx::migrate!("./migrations");
|
|
let subset: Vec<_> = full
|
|
.iter()
|
|
.filter(|m| m.version < version)
|
|
.cloned()
|
|
.collect();
|
|
Migrator {
|
|
migrations: Cow::Owned(subset),
|
|
ignore_missing: true,
|
|
locking: true,
|
|
}
|
|
}
|
|
|
|
async fn table_exists(pool: &SqlitePool, name: &str) -> bool {
|
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?")
|
|
.bind(name)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap()
|
|
> 0
|
|
}
|
|
|
|
async fn trigger_names(pool: &SqlitePool) -> Vec<String> {
|
|
sqlx::query_scalar::<_, String>(
|
|
"SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name",
|
|
)
|
|
.fetch_all(pool)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// Stage a DB at pre-0025 state with two 0024-era kit designations, then run the
|
|
/// rest of the migrations: the designations MUST be carried over, not dropped.
|
|
#[tokio::test]
|
|
async fn kit_assignments_migrate_into_club_active_items() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let db = dir.path().join("staged.db");
|
|
let pool = pool_for(&db).await;
|
|
migrator_upto(OWNED_CONTENT_MIGRATION)
|
|
.run(&pool)
|
|
.await
|
|
.expect("migrate to pre-0025");
|
|
assert!(
|
|
table_exists(&pool, "club_kit_assignments").await,
|
|
"staging must actually be at the 0024 schema"
|
|
);
|
|
|
|
let ts = "2026-01-01T00:00:00Z";
|
|
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p',?,?)")
|
|
.bind(ts)
|
|
.bind(ts)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
|
VALUES ('c','p','c',0,?,?)",
|
|
)
|
|
.bind(ts)
|
|
.bind(ts)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
for id in ["kit-h", "kit-a", "spare"] {
|
|
sqlx::query(
|
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
|
|
VALUES (?, 'c', ?, 0, ?)",
|
|
)
|
|
.bind(id)
|
|
.bind(format!("def-{id}"))
|
|
.bind(ts)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
for (slot, owned) in [("home", "kit-h"), ("away", "kit-a")] {
|
|
sqlx::query(
|
|
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
|
|
VALUES ('c', ?, ?, ?)",
|
|
)
|
|
.bind(slot)
|
|
.bind(owned)
|
|
.bind(ts)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
// Now the migrations under test.
|
|
sqlx::migrate!("./migrations")
|
|
.run(&pool)
|
|
.await
|
|
.expect("migrate to head");
|
|
|
|
assert!(
|
|
!table_exists(&pool, "club_kit_assignments").await,
|
|
"the old kit table must be gone"
|
|
);
|
|
assert!(table_exists(&pool, "club_active_items").await);
|
|
assert!(table_exists(&pool, "consumable_applications").await);
|
|
|
|
let rows =
|
|
sqlx::query("SELECT slot, owned_card_id, updated_at FROM club_active_items ORDER BY slot")
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
let carried: Vec<(String, String, String)> = rows
|
|
.iter()
|
|
.map(|r| (r.get(0), r.get(1), r.get(2)))
|
|
.collect();
|
|
assert_eq!(
|
|
carried,
|
|
vec![
|
|
(
|
|
ActiveSlot::AwayKit.as_str().into(),
|
|
"kit-a".to_string(),
|
|
ts.to_string()
|
|
),
|
|
(
|
|
ActiveSlot::HomeKit.as_str().into(),
|
|
"kit-h".to_string(),
|
|
ts.to_string()
|
|
),
|
|
],
|
|
"home -> home_kit, away -> away_kit, timestamps preserved"
|
|
);
|
|
|
|
// 0024's trigger is replaced, never merely orphaned: an ownership transfer
|
|
// must still clear the designation (and must not fail on a missing table).
|
|
let names = trigger_names(&pool).await;
|
|
assert!(
|
|
!names.contains(&"clear_club_kit_assignment_before_transfer".to_string()),
|
|
"the old trigger must be dropped, got {names:?}"
|
|
);
|
|
assert!(
|
|
names.contains(&"clear_club_active_item_before_transfer".to_string()),
|
|
"the generalised trigger must exist, got {names:?}"
|
|
);
|
|
sqlx::query(
|
|
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
|
VALUES ('c2','p','c2',0,?,?)",
|
|
)
|
|
.bind(ts)
|
|
.bind(ts)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("UPDATE owned_cards SET club_id = 'c2' WHERE id = 'kit-h'")
|
|
.execute(&pool)
|
|
.await
|
|
.expect("transfer must succeed after the trigger swap");
|
|
let remaining =
|
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_active_items WHERE club_id='c'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(remaining, 1, "the transferred kit's designation is cleared");
|
|
|
|
// Backfilled ownership reads back as the default kind with no stack size.
|
|
let (kind, quantity) = sqlx::query_as::<_, (ContentKind, Option<i64>)>(
|
|
"SELECT content_kind, quantity FROM owned_cards WHERE id = 'spare'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
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'")
|
|
.execute(&pool)
|
|
.await
|
|
.is_err(),
|
|
"content_kind CHECK must reject a token outside the vocabulary"
|
|
);
|
|
assert!(
|
|
sqlx::query("UPDATE owned_cards SET quantity = 0 WHERE id = 'spare'")
|
|
.execute(&pool)
|
|
.await
|
|
.is_err(),
|
|
"quantity CHECK must reject a non-positive stack"
|
|
);
|
|
assert!(
|
|
sqlx::query(
|
|
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) \
|
|
VALUES ('c', 'league_logo', 'spare', ?)"
|
|
)
|
|
.bind(ts)
|
|
.execute(&pool)
|
|
.await
|
|
.is_err(),
|
|
"slot CHECK must reject a token outside the recovered equipped-state set"
|
|
);
|
|
drop(dir);
|
|
}
|
|
|
|
/// The migrations must apply cleanly to a COPY of a REAL populated club DB,
|
|
/// leave every owned row intact, and carry a real kit designation over.
|
|
///
|
|
/// The snapshot predates migration 0024, so the copy is first brought up to the
|
|
/// 0024 schema and given two kit designations pointing at REAL owned instances;
|
|
/// only then do the migrations under test run. That way the carry-over is proved
|
|
/// on production ownership, not on synthetic rows.
|
|
///
|
|
/// Point `OPENFUT_CORE_SNAPSHOT_DB` at a real `core.db` to run it; without that
|
|
/// the test reports the skip rather than passing silently on nothing.
|
|
#[tokio::test]
|
|
async fn migrations_apply_to_a_real_populated_snapshot() {
|
|
let Ok(source) = std::env::var("OPENFUT_CORE_SNAPSHOT_DB") else {
|
|
eprintln!(
|
|
"SKIPPED migrations_apply_to_a_real_populated_snapshot: set \
|
|
OPENFUT_CORE_SNAPSHOT_DB=/path/to/core.db to run it"
|
|
);
|
|
return;
|
|
};
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let copy = dir.path().join("core.db");
|
|
// Copy, never open the source: the snapshot is read-only evidence.
|
|
std::fs::copy(&source, ©).unwrap_or_else(|e| panic!("copy {source}: {e}"));
|
|
let pool = pool_for(©).await;
|
|
|
|
let before = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("snapshot must already hold ownership");
|
|
assert!(
|
|
before > 0,
|
|
"the snapshot must be populated to prove anything"
|
|
);
|
|
|
|
// Bring the copy to the 0024 schema and designate two REAL owned instances
|
|
// as this club's kits, exactly as the pre-generalisation server would have.
|
|
migrator_upto(OWNED_CONTENT_MIGRATION)
|
|
.run(&pool)
|
|
.await
|
|
.expect("migrate the snapshot to pre-0025");
|
|
let real: Vec<(String, String)> =
|
|
sqlx::query_as("SELECT id, club_id FROM owned_cards ORDER BY id LIMIT 2")
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(real.len(), 2, "need two real owned instances");
|
|
let ts = "2026-01-01T00:00:00Z";
|
|
for (slot, (owned_id, club_id)) in ["home", "away"].into_iter().zip(&real) {
|
|
sqlx::query(
|
|
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
|
|
VALUES (?, ?, ?, ?)",
|
|
)
|
|
.bind(club_id)
|
|
.bind(slot)
|
|
.bind(owned_id)
|
|
.bind(ts)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("stage a real kit designation");
|
|
}
|
|
|
|
sqlx::migrate!("./migrations")
|
|
.run(&pool)
|
|
.await
|
|
.expect("migrations must apply to real populated data");
|
|
|
|
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 contract_matches IS NOT NULL THEN 1 ELSE 0 END) \
|
|
FROM owned_cards",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
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);
|
|
assert!(table_exists(&pool, "consumable_applications").await);
|
|
|
|
let carried: Vec<(String, String)> =
|
|
sqlx::query_as("SELECT slot, owned_card_id FROM club_active_items ORDER BY slot")
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
carried,
|
|
vec![
|
|
(ActiveSlot::AwayKit.as_str().into(), real[1].0.clone()),
|
|
(ActiveSlot::HomeKit.as_str().into(), real[0].0.clone()),
|
|
],
|
|
"real kit designations must land in club_active_items"
|
|
);
|
|
eprintln!(
|
|
"snapshot: {after} owned rows survive as content_kind='player'; \
|
|
designations carried over: {carried:?}"
|
|
);
|
|
drop(dir);
|
|
}
|