feat(import): generic transactional profile-import service + CLI

Core performs a GAME-AGNOSTIC transactional profile import; all FIFA17 semantics
(manifest parse, wire ids, resourceId/nextItemId, squad extension v1,
CardDefinitionId/OwnedItemId choice) stay in openfut-import-fifa17. Core sees
only opaque ids and opaque extension bytes.

services::import::apply_profile_import(pool, card_db, ProfileImportRequest):
- ONE SQLite transaction installs profile + club + all owned cards + canonical
  squad + one opaque game extension; commits together or not at all.
- Definition preflight (pre-tx): every owned card_id MUST resolve in loaded
  production content, so ownership never points at absent content.
- Squad all-or-nothing (pre-tx): every active-squad OwnedItemId MUST be in the
  imported ownership set.
- OwnedItemId uniqueness + generic extension bounds enforced pre-tx.
- Core computes the canonical squad fingerprint itself (never adapter-supplied)
  and persists the extension atomically, exactly as the live squad-write path.

Rerun identity via profiles.import_fingerprint (migration 0018, nullable):
- identical source_fingerprint on an already-imported game -> idempotent no-op;
- differing token -> fail (needs explicit update mode);
- pre-existing non-imported profile -> never clobbered.
So a crash after identity-seeding re-runs cleanly with no cleanup/reminting.

CLI: 'openfut-core import <request.json>' loads production content packs, parses
a generic request, applies. squad_fingerprint made pub(crate) for reuse.

8 import-service tests (happy path, idempotent rerun, fingerprint mismatch,
missing-definition no-write, squad-not-owned, dup OwnedItemId, non-imported
clobber guard, empty-owned). clippy -D warnings clean; full suite 159 green.
This commit is contained in:
funman300
2026-08-12 20:01:27 +00:00
parent 352ad11bc4
commit 9f3c545c46
6 changed files with 614 additions and 4 deletions
+318
View File
@@ -0,0 +1,318 @@
//! 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 crate::db::Pool;
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,
}
#[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>,
}
#[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");
}
// ── 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) \
VALUES (?, ?, ?, 0, NULL, ?)",
)
.bind(&o.owned_item_id)
.bind(&club_id)
.bind(&o.card_id)
.bind(&now)
.execute(&mut *tx)
.await
.with_context(|| format!("insert owned_card {}", o.owned_item_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,
})
}
+3 -2
View File
@@ -2,19 +2,20 @@ pub mod achievement;
pub mod card_db;
pub mod checkin;
pub mod club;
pub mod notification;
pub mod draft;
pub mod event;
pub mod fut_champs;
pub mod game_ext;
pub mod import;
pub mod inventory;
pub mod season;
pub mod market;
pub mod match_service;
pub mod notification;
pub mod objective;
pub mod pack;
pub mod profile;
pub mod sbc;
pub mod season;
pub mod settings;
pub mod squad;
pub mod squad_rules;
+1 -1
View File
@@ -497,7 +497,7 @@ pub async fn replace_squad_with_extension(
/// computed; non-cryptographic (FNV-1a-64) — a stale-extension guard, not a
/// security boundary. The encoding is sorted + delimited so it never depends on
/// row/iteration order.
fn squad_fingerprint<'a>(
pub(crate) fn squad_fingerprint<'a>(
squad_id: &str,
formation: &str,
slots: impl Iterator<Item = (i64, &'a str, bool, bool)>,