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
+26 -1
View File
@@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{Context, Result};
use openfut_core::{config, db, seed};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
@@ -30,6 +30,31 @@ async fn main() -> Result<()> {
return Ok(());
}
// Opt-in generic import subcommand: `openfut-core import <request.json>`.
// Reads a GAME-AGNOSTIC ProfileImportRequest (the importer adapter translates
// FIFA17 source data into it), loads production content packs, runs preflight,
// and applies one all-or-nothing transaction. FIFA17 semantics live entirely
// in the adapter; Core only sees opaque ids + opaque extension bytes.
if std::env::args().nth(1).as_deref() == Some("import") {
let path = std::env::args()
.nth(2)
.context("usage: openfut-core import <request.json>")?;
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
db::run_migrations(&pool).await?;
let mut card_db = openfut_core::services::card_db::CardDb::load(&cfg.data_dir)?;
for pack in &cfg.content_packs {
card_db.load_pack(pack)?;
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read import request {path}"))?;
let req: openfut_core::services::import::ProfileImportRequest =
serde_json::from_str(&raw).context("parse import request JSON")?;
let outcome =
openfut_core::services::import::apply_profile_import(&pool, &card_db, &req).await?;
println!("{}", serde_json::to_string_pretty(&outcome)?);
return Ok(());
}
info!("OpenFUT Core starting on {}", cfg.listen_addr);
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;