233df1d99d
CI / Build, lint & test (push) Successful in 3m7s
Production is frozen and sits on schema 19, so the content_kind correction it
needs cannot be applied yet. `--dry-run` runs the real UPDATEs and rolls the
transaction back, so an operator can see exactly what a run would touch before
touching a database they cannot easily restore — the counts are measured, not
predicted.
`updated_by_kind` reports the SHAPE of the change ("consumable 17, staff 3"),
which is the thing worth sanity-checking: a different shape means the source
profile moved and the mapping needs regenerating.
Measured against a copy of prod-core.db: 20 rows would change (17 consumable,
3 staff), 1966 already correct, 0 unmatched, and the copy was verified unchanged
afterwards.
490 lines
17 KiB
Rust
490 lines
17 KiB
Rust
//! Generic, game-agnostic transactional profile import.
|
|
//!
|
|
//! Core installs a profile + club + owned cards + canonical squad + one opaque
|
|
//! game extension in a SINGLE all-or-nothing SQLite transaction, stamped with a
|
|
//! generic `source_fingerprint` provenance token. Core NEVER interprets FIFA17
|
|
//! wire ids, resourceIds, `nextItemId`, or the extension payload — the
|
|
//! `openfut-import-fifa17` adapter reads the Python profile, chooses every
|
|
//! `CardDefinitionId` and every opaque `OwnedItemId`, builds the squad
|
|
//! extension bytes, and hands Core this generic request.
|
|
//!
|
|
//! Invariants enforced here:
|
|
//! - Definition preflight: every incoming `card_id` MUST already resolve in the
|
|
//! loaded production content, so the transaction never creates ownership
|
|
//! pointing at absent content.
|
|
//! - Squad all-or-nothing: every active-squad `owned_item_id` MUST be among the
|
|
//! imported ownership set before the transaction begins.
|
|
//! - Rerun identity: identical `source_fingerprint` against an already-imported
|
|
//! game is an idempotent no-op; a differing token fails; a pre-existing
|
|
//! non-imported profile is never clobbered.
|
|
//! - The whole thing commits together or not at all.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use crate::db::Pool;
|
|
use crate::models::card::ContentKind;
|
|
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
|
|
use crate::services::card_db::CardDb;
|
|
use crate::services::squad::squad_fingerprint;
|
|
use anyhow::{bail, Context, Result};
|
|
use chrono::Utc;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashSet;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ImportProfile {
|
|
pub username: String,
|
|
pub game_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ImportClub {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub coins: i64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ImportOwnedCard {
|
|
/// Opaque, stable Core OwnedItemId chosen by the adapter. Core never parses
|
|
/// why it is stable — it is a primary key, nothing more.
|
|
pub owned_item_id: String,
|
|
/// CardDefinitionId that MUST resolve in loaded production content.
|
|
pub card_id: String,
|
|
/// Generic content classification. Absent = `player`, which is what every
|
|
/// pre-taxonomy import produced; the adapter maps its own taxonomy (FIFA 17
|
|
/// `cardsubtypeid`, resource ranges, …) onto this before calling Core.
|
|
#[serde(default)]
|
|
pub content_kind: ContentKind,
|
|
/// Optional per-instance stack size. Absent / `null` means "not a stack"; it
|
|
/// never collapses two instances into one row.
|
|
///
|
|
/// NOT a consumable's wire `amount`: in FIFA 17 that field is the
|
|
/// definition's effect magnitude (a "+15 training" card), and every copy is
|
|
/// its own instance carrying the same value, so storing it here would claim
|
|
/// the club owns fifteen of them.
|
|
#[serde(default)]
|
|
pub quantity: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ImportEntitlement {
|
|
/// Opaque definition reference for one unconsumed entitlement (e.g. a pack
|
|
/// id as text). Core stores it verbatim; it never interprets the value.
|
|
pub definition_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ImportSlot {
|
|
pub owned_item_id: String,
|
|
pub position_index: i64,
|
|
#[serde(default)]
|
|
pub is_captain: bool,
|
|
#[serde(default)]
|
|
pub is_on_bench: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ImportExtension {
|
|
/// Opaque adapter key, e.g. "fifa17.squad.v1".
|
|
pub namespace: String,
|
|
/// Adapter payload version (distinct from DB storage schema).
|
|
pub schema_version: i64,
|
|
/// Uninterpreted bytes-as-text. Core enforces only generic size bounds.
|
|
pub payload: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ImportSquad {
|
|
pub formation: String,
|
|
#[serde(default = "default_squad_name")]
|
|
pub name: String,
|
|
pub slots: Vec<ImportSlot>,
|
|
pub extension: ImportExtension,
|
|
}
|
|
|
|
fn default_squad_name() -> String {
|
|
"My Squad".to_string()
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ProfileImportRequest {
|
|
/// Generic provenance/rerun-identity token. Core stores it verbatim.
|
|
pub source_fingerprint: String,
|
|
pub profile: ImportProfile,
|
|
pub club: ImportClub,
|
|
pub owned: Vec<ImportOwnedCard>,
|
|
#[serde(default)]
|
|
pub squad: Option<ImportSquad>,
|
|
/// Unconsumed entitlements to seed (e.g. from a source's unopened packs).
|
|
#[serde(default)]
|
|
pub entitlements: Vec<ImportEntitlement>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
|
#[serde(tag = "outcome", rename_all = "snake_case")]
|
|
pub enum ImportOutcome {
|
|
/// A fresh import committed.
|
|
Imported { owned: usize, squad_slots: usize },
|
|
/// The same fingerprint was already imported for this game — no-op.
|
|
AlreadyImported,
|
|
}
|
|
|
|
/// Apply a generic transactional profile import. See module docs for invariants.
|
|
pub async fn apply_profile_import(
|
|
pool: &Pool,
|
|
card_db: &CardDb,
|
|
req: &ProfileImportRequest,
|
|
) -> Result<ImportOutcome> {
|
|
// ── 0. generic input validation (no writes) ──
|
|
if req.source_fingerprint.trim().is_empty() {
|
|
bail!("source_fingerprint must be non-empty");
|
|
}
|
|
if req.owned.is_empty() {
|
|
bail!("import request has zero owned cards; refusing to import an empty profile");
|
|
}
|
|
// A stack size is either absent ("not a stack") or a real positive count.
|
|
// Reject an explicit 0/negative up front rather than letting the column
|
|
// CHECK surface it as an opaque constraint failure mid-transaction.
|
|
for o in &req.owned {
|
|
if let Some(q) = o.quantity {
|
|
if q < 1 {
|
|
bail!(
|
|
"owned card {} has quantity {q}; a stack size must be omitted or >= 1",
|
|
o.owned_item_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 1. rerun identity / single-profile-per-game ──
|
|
let existing: Option<(String, Option<String>)> = sqlx::query_as(
|
|
"SELECT id, import_fingerprint FROM profiles \
|
|
WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
|
)
|
|
.bind(&req.profile.game_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
if let Some((_id, fp)) = existing {
|
|
match fp {
|
|
Some(fp) if fp == req.source_fingerprint => return Ok(ImportOutcome::AlreadyImported),
|
|
Some(fp) => bail!(
|
|
"game '{}' already imported from a different source (stored fingerprint {fp}, \
|
|
incoming {}); refusing to overwrite without an explicit update mode",
|
|
req.profile.game_id,
|
|
req.source_fingerprint
|
|
),
|
|
None => bail!(
|
|
"game '{}' already has a non-imported profile; refusing to clobber it",
|
|
req.profile.game_id
|
|
),
|
|
}
|
|
}
|
|
|
|
// ── 2. definition preflight: every card_id MUST resolve in loaded content ──
|
|
let mut missing: Vec<&str> = req
|
|
.owned
|
|
.iter()
|
|
.filter(|o| card_db.get(&o.card_id).is_none())
|
|
.map(|o| o.card_id.as_str())
|
|
.collect();
|
|
if !missing.is_empty() {
|
|
missing.sort_unstable();
|
|
missing.dedup();
|
|
let sample = &missing[..missing.len().min(5)];
|
|
bail!(
|
|
"definition preflight failed: {} owned card(s) reference CardDefinitionId(s) not in \
|
|
loaded content (e.g. {sample:?}); refusing to create ownership pointing at absent content",
|
|
missing.len()
|
|
);
|
|
}
|
|
|
|
// ── 3. owned-item-id uniqueness ──
|
|
let mut owned_ids: HashSet<&str> = HashSet::with_capacity(req.owned.len());
|
|
for o in &req.owned {
|
|
if !owned_ids.insert(o.owned_item_id.as_str()) {
|
|
bail!(
|
|
"duplicate OwnedItemId in import request: {}",
|
|
o.owned_item_id
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 4. squad all-or-nothing + generic extension bounds (no writes) ──
|
|
if let Some(sq) = &req.squad {
|
|
let ns_len = sq.extension.namespace.len();
|
|
if ns_len == 0 || ns_len > MAX_EXT_NAMESPACE_LEN {
|
|
bail!(
|
|
"extension namespace length {ns_len} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})"
|
|
);
|
|
}
|
|
if sq.extension.payload.len() > MAX_EXT_PAYLOAD_BYTES {
|
|
bail!(
|
|
"extension payload {} bytes exceeds MAX_EXT_PAYLOAD_BYTES ({MAX_EXT_PAYLOAD_BYTES})",
|
|
sq.extension.payload.len()
|
|
);
|
|
}
|
|
for slot in &sq.slots {
|
|
if !owned_ids.contains(slot.owned_item_id.as_str()) {
|
|
bail!(
|
|
"active squad references OwnedItemId {} not present in imported ownership set; \
|
|
squad import is all-or-nothing",
|
|
slot.owned_item_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 5. single transaction: everything commits together or not at all ──
|
|
let now = Utc::now().to_rfc3339();
|
|
let profile_id = Uuid::new_v4().to_string();
|
|
let club_id = Uuid::new_v4().to_string();
|
|
let mut tx = pool.begin().await?;
|
|
|
|
sqlx::query(
|
|
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at, import_fingerprint) \
|
|
VALUES (?, ?, 1, 0, ?, ?, ?, ?)",
|
|
)
|
|
.bind(&profile_id)
|
|
.bind(&req.profile.username)
|
|
.bind(&req.profile.game_id)
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.bind(&req.source_fingerprint)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.context("insert profile")?;
|
|
|
|
sqlx::query(
|
|
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) \
|
|
VALUES (?, ?, ?, ?, 1, ?, ?)",
|
|
)
|
|
.bind(&club_id)
|
|
.bind(&profile_id)
|
|
.bind(&req.club.name)
|
|
.bind(req.club.coins)
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.context("insert club")?;
|
|
|
|
for o in &req.owned {
|
|
sqlx::query(
|
|
"INSERT INTO owned_cards \
|
|
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
|
content_kind, quantity) \
|
|
VALUES (?, ?, ?, 0, NULL, ?, ?, ?)",
|
|
)
|
|
.bind(&o.owned_item_id)
|
|
.bind(&club_id)
|
|
.bind(&o.card_id)
|
|
.bind(&now)
|
|
.bind(o.content_kind.as_str())
|
|
.bind(o.quantity)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
|
}
|
|
|
|
for e in &req.entitlements {
|
|
sqlx::query(
|
|
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
|
|
)
|
|
.bind(Uuid::new_v4().to_string())
|
|
.bind(&club_id)
|
|
.bind(&e.definition_id)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.with_context(|| format!("insert entitlement {}", e.definition_id))?;
|
|
}
|
|
|
|
let mut squad_slots = 0usize;
|
|
if let Some(sq) = &req.squad {
|
|
let squad_id = Uuid::new_v4().to_string();
|
|
sqlx::query(
|
|
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \
|
|
VALUES (?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(&squad_id)
|
|
.bind(&club_id)
|
|
.bind(&sq.name)
|
|
.bind(&sq.formation)
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.context("insert squad")?;
|
|
|
|
for slot in &sq.slots {
|
|
sqlx::query(
|
|
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) \
|
|
VALUES (?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(Uuid::new_v4().to_string())
|
|
.bind(&squad_id)
|
|
.bind(&slot.owned_item_id)
|
|
.bind(slot.position_index)
|
|
.bind(slot.is_captain)
|
|
.bind(slot.is_on_bench)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.context("insert squad_player")?;
|
|
}
|
|
squad_slots = sq.slots.len();
|
|
|
|
// Core computes the canonical fingerprint over the COMMITTED squad — never
|
|
// an adapter-supplied value — and persists the opaque extension atomically
|
|
// in the same tx, exactly as the live squad-write path does.
|
|
let canonical_fingerprint = squad_fingerprint(
|
|
&squad_id,
|
|
&sq.formation,
|
|
sq.slots.iter().map(|s| {
|
|
(
|
|
s.position_index,
|
|
s.owned_item_id.as_str(),
|
|
s.is_captain,
|
|
s.is_on_bench,
|
|
)
|
|
}),
|
|
);
|
|
sqlx::query(
|
|
"INSERT OR REPLACE INTO game_entity_ext \
|
|
(game_id, entity_kind, entity_id, namespace, schema_version, canonical_fingerprint, payload, updated_at) \
|
|
VALUES (?, 'squad', ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(&req.profile.game_id)
|
|
.bind(&squad_id)
|
|
.bind(&sq.extension.namespace)
|
|
.bind(sq.extension.schema_version)
|
|
.bind(&canonical_fingerprint)
|
|
.bind(&sq.extension.payload)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.context("insert game_entity_ext")?;
|
|
}
|
|
|
|
tx.commit().await?;
|
|
Ok(ImportOutcome::Imported {
|
|
owned: req.owned.len(),
|
|
squad_slots,
|
|
})
|
|
}
|
|
|
|
/// One adapter-supplied classification: "every owned row of this definition is
|
|
/// really this kind of content".
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ContentKindAssignment {
|
|
pub card_id: String,
|
|
pub content_kind: ContentKind,
|
|
}
|
|
|
|
/// Request for [`reclassify_owned_content`].
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ReclassifyRequest {
|
|
pub game_id: String,
|
|
pub assignments: Vec<ContentKindAssignment>,
|
|
/// Compute the outcome and roll back instead of committing. Lets an operator
|
|
/// see exactly what a production run would touch before it touches it.
|
|
#[serde(default)]
|
|
pub dry_run: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
|
pub struct ReclassifyOutcome {
|
|
/// Rows whose `content_kind` actually changed. On a dry run, the rows that
|
|
/// WOULD change; nothing is committed.
|
|
pub updated: usize,
|
|
/// Rows already carrying the requested kind (a rerun updates nothing).
|
|
pub unchanged: usize,
|
|
/// Assignments naming a definition this game owns no copy of.
|
|
pub unmatched_definitions: Vec<String>,
|
|
/// True when the transaction was rolled back rather than committed.
|
|
pub dry_run: bool,
|
|
/// Per-kind tally of the rows that changed, so an operator can sanity-check
|
|
/// the shape of the change ("3 staff, 17 consumable") before committing.
|
|
pub updated_by_kind: BTreeMap<String, usize>,
|
|
}
|
|
|
|
/// Correct the `content_kind` of ALREADY-IMPORTED owned rows, in one transaction.
|
|
///
|
|
/// A profile import is once-only (same fingerprint no-ops, a different one is
|
|
/// refused), so a taxonomy fix cannot arrive by re-importing. Core defaults an
|
|
/// unstated row to `player`, which means every pre-taxonomy import durably
|
|
/// recorded coaches, kits and consumables as players — wrong in the ownership
|
|
/// authority even where a catalog-driven wire looked right.
|
|
///
|
|
/// Core stays generic: the caller supplies `card_id -> kind`, because only the
|
|
/// game adapter can map its own taxonomy. Idempotent, and scoped to one game's
|
|
/// clubs so a shared database cannot be reclassified across games.
|
|
pub async fn reclassify_owned_content(
|
|
pool: &Pool,
|
|
req: &ReclassifyRequest,
|
|
) -> Result<ReclassifyOutcome> {
|
|
if req.assignments.is_empty() {
|
|
bail!("reclassify request has zero assignments");
|
|
}
|
|
let mut tx = pool.begin().await?;
|
|
let mut updated = 0usize;
|
|
let mut unchanged = 0usize;
|
|
let mut unmatched = Vec::new();
|
|
let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
|
|
for a in &req.assignments {
|
|
// Scope by game through the owning club, so the same definition id in
|
|
// another game is never touched.
|
|
let present: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM owned_cards o JOIN clubs c ON c.id = o.club_id \
|
|
JOIN profiles p ON p.id = c.profile_id \
|
|
WHERE p.game_id = ? AND o.card_id = ?",
|
|
)
|
|
.bind(&req.game_id)
|
|
.bind(&a.card_id)
|
|
.fetch_one(&mut *tx)
|
|
.await
|
|
.context("count owned rows for definition")?;
|
|
if present == 0 {
|
|
unmatched.push(a.card_id.clone());
|
|
continue;
|
|
}
|
|
let changed = sqlx::query(
|
|
"UPDATE owned_cards SET content_kind = ? \
|
|
WHERE card_id = ? AND content_kind != ? AND club_id IN \
|
|
(SELECT c.id FROM clubs c JOIN profiles p ON p.id = c.profile_id \
|
|
WHERE p.game_id = ?)",
|
|
)
|
|
.bind(a.content_kind.as_str())
|
|
.bind(&a.card_id)
|
|
.bind(a.content_kind.as_str())
|
|
.bind(&req.game_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.context("update owned content_kind")?
|
|
.rows_affected() as usize;
|
|
updated += changed;
|
|
unchanged += present as usize - changed;
|
|
if changed > 0 {
|
|
*by_kind
|
|
.entry(a.content_kind.as_str().to_string())
|
|
.or_default() += changed;
|
|
}
|
|
}
|
|
// A dry run does the real UPDATEs and then throws them away, so the counts
|
|
// it reports are measured rather than predicted — the same statements, the
|
|
// same WHERE clauses, just no commit.
|
|
if req.dry_run {
|
|
tx.rollback().await?;
|
|
} else {
|
|
tx.commit().await?;
|
|
}
|
|
Ok(ReclassifyOutcome {
|
|
updated,
|
|
unchanged,
|
|
unmatched_definitions: unmatched,
|
|
dry_run: req.dry_run,
|
|
updated_by_kind: by_kind,
|
|
})
|
|
}
|