//! # openfut-identity //! //! A **generic, game-scoped external-identity store**: a durable, reversible //! mapping between an opaque OpenFUT core id and a game's external wire id, //! keyed by `(game_id, entity_kind, core_id)`. //! //! It is game-independent infrastructure. It knows nothing about FIFA — a game //! adapter supplies the numeric **policy** (e.g. FIFA 17 owned-item ids start at //! `100_000_000`) by passing a `base_floor`; the store only guarantees the //! structural properties every adapter needs: //! //! * **stable** — the same `(game, kind, core_id)` always resolves to the same //! external id, including after restart; //! * **unique** — two distinct core ids in a scope never share an external id; //! * **reversible** — an external id resolves back to its exact core id; //! * **game-scoped** — `fifa17` and a future `fifa23` allocate in isolation; //! * **persistent** — not process memory; //! * **atomic** — allocation is serialized (one host process owns the file) and //! persisted with a temp+rename so a crash cannot leave a torn mapping; //! * **explicit** — an unknown core id or external id returns `None`, never a //! fabricated value. //! //! Core never learns the external (FIFA) integer: only the host/adapter that //! owns a game boundary uses this store. Persistence is a small JSON file (the //! smallest durable mechanism; the host uses no database today). The //! [`ExternalIdentityStore`] trait keeps that a swappable detail — a SQLite //! implementation can drop in later without touching callers. use std::collections::HashMap; use std::path::{Path, PathBuf}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; /// Store errors. #[derive(Debug)] pub enum IdError { Io(std::io::Error), Corrupt(String), /// A caller-supplied import mapping conflicts with an existing one. Conflict(String), } impl std::fmt::Display for IdError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { IdError::Io(e) => write!(f, "identity store io: {e}"), IdError::Corrupt(e) => write!(f, "identity store corrupt: {e}"), IdError::Conflict(e) => write!(f, "identity store conflict: {e}"), } } } impl std::error::Error for IdError {} /// A generic, durable, reversible external-identity mapping. pub trait ExternalIdentityStore: Send + Sync { /// Return the external id for `(game, kind, core_id)`, allocating a new one /// if absent. New ids are monotonic within the `(game, kind)` scope, never /// below `base_floor` (the adapter's namespace policy). Idempotent: an /// existing mapping is returned unchanged (base_floor ignored then). fn resolve_or_allocate( &self, game: &str, kind: &str, core_id: &str, base_floor: i64, ) -> Result; /// Existing external id for a core id, or `None` (never allocates). fn external_for(&self, game: &str, kind: &str, core_id: &str) -> Result, IdError>; /// Reverse lookup: the core id that owns `external_id`, or `None`. fn core_for(&self, game: &str, kind: &str, external_id: i64) -> Result, IdError>; } /// One persisted mapping row. #[derive(Debug, Clone, Serialize, Deserialize)] struct Row { game_id: String, entity_kind: String, core_id: String, external_id: i64, } /// A persisted per-scope allocator watermark: the next external id to issue, /// imported from a source system so historical allocation (including ids burned /// in gaps) is preserved, not just currently-live mappings. #[derive(Debug, Clone, Serialize, Deserialize)] struct Watermark { game_id: String, entity_kind: String, next_id: i64, } /// On-disk store file. Backward-compatible: a legacy bare `[Row, ...]` array is /// still accepted on load; new writes always use this object form so watermarks /// persist alongside the rows. #[derive(Debug, Default, Serialize, Deserialize)] struct StoreFile { #[serde(default)] rows: Vec, #[serde(default)] watermarks: Vec, } #[derive(Default)] struct Index { rows: Vec, fwd: HashMap<(String, String, String), i64>, rev: HashMap<(String, String, i64), String>, max: HashMap<(String, String), i64>, /// Per-scope imported allocation watermark (next id to issue): a floor that /// preserves source allocation history beyond the live `max`. watermarks: HashMap<(String, String), i64>, } impl Index { fn insert(&mut self, row: Row) { let scope = (row.game_id.clone(), row.entity_kind.clone()); self.fwd.insert( ( row.game_id.clone(), row.entity_kind.clone(), row.core_id.clone(), ), row.external_id, ); self.rev.insert( ( row.game_id.clone(), row.entity_kind.clone(), row.external_id, ), row.core_id.clone(), ); let m = self.max.entry(scope).or_insert(i64::MIN); if row.external_id > *m { *m = row.external_id; } self.rows.push(row); } } /// The next external id to issue in a scope: the maximum of the namespace floor, /// one past the highest live mapping, and any imported watermark. Taking the max /// of all three preserves source allocation history (burned-id gaps) while never /// reissuing a live id nor dropping below the adapter's policy floor. fn next_external(ix: &Index, game: &str, kind: &str, base_floor: i64) -> i64 { let scope = (game.to_string(), kind.to_string()); let from_max = ix.max.get(&scope).map(|m| m + 1); let from_wm = ix.watermarks.get(&scope).copied(); [Some(base_floor), from_max, from_wm] .into_iter() .flatten() .max() .unwrap_or(base_floor) } /// JSON-file backed [`ExternalIdentityStore`]. Single-writer (one host process). pub struct JsonIdentityStore { path: PathBuf, inner: Mutex, } impl JsonIdentityStore { /// Open (or create) the store at `path`, loading any existing mappings. pub fn open(path: impl AsRef) -> Result { let path = path.as_ref().to_path_buf(); let mut index = Index::default(); if path.exists() { let raw = std::fs::read_to_string(&path).map_err(IdError::Io)?; if !raw.trim().is_empty() { // Accept the modern object form; fall back to the legacy bare // `[Row, ...]` array so pre-watermark stores keep loading. let file: StoreFile = match serde_json::from_str::(&raw) { Ok(f) => f, Err(_) => { let rows: Vec = serde_json::from_str(&raw) .map_err(|e| IdError::Corrupt(e.to_string()))?; StoreFile { rows, watermarks: Vec::new(), } } }; for row in file.rows { // Reject a torn file that violates reverse-uniqueness rather // than silently serving an ambiguous reverse lookup. let rkey = ( row.game_id.clone(), row.entity_kind.clone(), row.external_id, ); if index.rev.contains_key(&rkey) { return Err(IdError::Corrupt(format!( "duplicate external id {} in scope ({}, {})", row.external_id, row.game_id, row.entity_kind ))); } index.insert(row); } for wm in file.watermarks { index .watermarks .insert((wm.game_id, wm.entity_kind), wm.next_id); } } } Ok(JsonIdentityStore { path, inner: Mutex::new(index), }) } /// Atomically persist the full row set (temp + rename). fn persist(&self, index: &Index) -> Result<(), IdError> { let file = StoreFile { rows: index.rows.clone(), watermarks: index .watermarks .iter() .map(|((g, k), &n)| Watermark { game_id: g.clone(), entity_kind: k.clone(), next_id: n, }) .collect(), }; let json = serde_json::to_vec_pretty(&file).map_err(|e| IdError::Corrupt(e.to_string()))?; let tmp = self.path.with_extension("tmp"); std::fs::write(&tmp, &json).map_err(IdError::Io)?; std::fs::rename(&tmp, &self.path).map_err(IdError::Io)?; Ok(()) } /// Register an EXISTING external id for `(game, kind, core_id)` instead of /// allocating one — the import path that preserves a source system's wire /// ids. Idempotent for an identical mapping; rejects a conflicting forward /// (core already mapped elsewhere) or reverse (external already owned) /// mapping with [`IdError::Conflict`]; persists both directions atomically. pub fn insert_existing_mapping( &self, game: &str, kind: &str, core_id: &str, external_id: i64, ) -> Result<(), IdError> { let mut ix = self.inner.lock(); let fkey = (game.to_string(), kind.to_string(), core_id.to_string()); let rkey = (game.to_string(), kind.to_string(), external_id); let fwd_existing = ix.fwd.get(&fkey).copied(); let rev_existing = ix.rev.get(&rkey).cloned(); if let Some(e) = fwd_existing { if e == external_id && rev_existing.as_deref() == Some(core_id) { return Ok(()); // identical mapping — idempotent } return Err(IdError::Conflict(format!( "core '{core_id}' already maps to {e} in ({game}, {kind}), not {external_id}" ))); } if let Some(c) = rev_existing { return Err(IdError::Conflict(format!( "external {external_id} already owned by core '{c}' in ({game}, {kind}), not '{core_id}'" ))); } ix.insert(Row { game_id: game.to_string(), entity_kind: kind.to_string(), core_id: core_id.to_string(), external_id, }); if let Err(e) = self.persist(&ix) { // roll the in-memory insert back so memory and disk never diverge ix.rows.pop(); ix.fwd.remove(&fkey); ix.rev.remove(&rkey); let scope = (game.to_string(), kind.to_string()); let new_max = ix .rows .iter() .filter(|r| r.game_id == game && r.entity_kind == kind) .map(|r| r.external_id) .max(); match new_max { Some(m) => { ix.max.insert(scope, m); } None => { ix.max.remove(&scope); } } return Err(e); } Ok(()) } /// Set the per-scope allocator watermark (the next external id to issue), /// imported from the source system so future allocations continue past its /// historical high-water even where those ids left no live mapping (gaps). /// A floor: it never lowers the effective next id below the live max. pub fn set_watermark(&self, game: &str, kind: &str, next_id: i64) -> Result<(), IdError> { let mut ix = self.inner.lock(); let scope = (game.to_string(), kind.to_string()); let prev = ix.watermarks.insert(scope.clone(), next_id); if let Err(e) = self.persist(&ix) { match prev { Some(p) => { ix.watermarks.insert(scope, p); } None => { ix.watermarks.remove(&scope); } } return Err(e); } Ok(()) } /// The imported watermark for a scope, if any. pub fn watermark_for(&self, game: &str, kind: &str) -> Option { self.inner .lock() .watermarks .get(&(game.to_string(), kind.to_string())) .copied() } /// The external id the next allocation in this scope WOULD receive, without /// allocating. For dry-run reporting. pub fn peek_next_external(&self, game: &str, kind: &str, base_floor: i64) -> i64 { let ix = self.inner.lock(); next_external(&ix, game, kind, base_floor) } } impl ExternalIdentityStore for JsonIdentityStore { fn resolve_or_allocate( &self, game: &str, kind: &str, core_id: &str, base_floor: i64, ) -> Result { let mut ix = self.inner.lock(); let fkey = (game.to_string(), kind.to_string(), core_id.to_string()); if let Some(&ext) = ix.fwd.get(&fkey) { return Ok(ext); } let scope = (game.to_string(), kind.to_string()); let next = next_external(&ix, game, kind, base_floor); let row = Row { game_id: game.to_string(), entity_kind: kind.to_string(), core_id: core_id.to_string(), external_id: next, }; ix.insert(row); // Persist before returning; on failure roll the in-memory insert back so // memory and disk never diverge. if let Err(e) = self.persist(&ix) { ix.rows.pop(); ix.fwd.remove(&fkey); ix.rev.remove(&(game.to_string(), kind.to_string(), next)); // `max` may now be stale-high; recompute for the scope. let new_max = ix .rows .iter() .filter(|r| r.game_id == game && r.entity_kind == kind) .map(|r| r.external_id) .max(); match new_max { Some(m) => { ix.max.insert(scope, m); } None => { ix.max.remove(&scope); } } return Err(e); } Ok(next) } fn external_for(&self, game: &str, kind: &str, core_id: &str) -> Result, IdError> { let ix = self.inner.lock(); Ok(ix .fwd .get(&(game.to_string(), kind.to_string(), core_id.to_string())) .copied()) } fn core_for( &self, game: &str, kind: &str, external_id: i64, ) -> Result, IdError> { let ix = self.inner.lock(); Ok(ix .rev .get(&(game.to_string(), kind.to_string(), external_id)) .cloned()) } } #[cfg(test)] mod tests { use super::*; fn store() -> (JsonIdentityStore, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); let s = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap(); (s, dir) } const G: &str = "fifa17"; const K: &str = "owned-item"; const BASE: i64 = 100_000_001; #[test] fn allocation_is_stable_and_reversible() { let (s, _d) = store(); let a = s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(); // idempotent assert_eq!(s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(), a); assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(a)); // reversible assert_eq!(s.core_for(G, K, a).unwrap().as_deref(), Some("oc-A")); } #[test] fn first_allocation_respects_base_floor_then_monotonic() { let (s, _d) = store(); let a = s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(); let b = s.resolve_or_allocate(G, K, "oc-B", BASE).unwrap(); assert_eq!(a, BASE); assert_eq!(b, BASE + 1); assert_ne!(a, b, "two owned items get distinct wire ids"); } #[test] fn unknown_ids_are_explicit_none() { let (s, _d) = store(); assert_eq!(s.external_for(G, K, "nope").unwrap(), None); assert_eq!(s.core_for(G, K, 999_999).unwrap(), None); } #[test] fn game_and_kind_scopes_are_isolated() { let (s, _d) = store(); let f = s.resolve_or_allocate("fifa17", K, "oc-A", BASE).unwrap(); let g = s.resolve_or_allocate("fifa23", K, "oc-A", BASE).unwrap(); // same core id, different games -> independent allocations, both from base assert_eq!(f, BASE); assert_eq!(g, BASE); // reverse lookup respects the game scope assert_eq!(s.core_for("fifa17", K, f).unwrap().as_deref(), Some("oc-A")); assert_eq!(s.core_for("fifa23", K, f).unwrap().as_deref(), Some("oc-A")); // a different entity kind is its own scope let k2 = s .resolve_or_allocate("fifa17", "card-def", "oc-A", 1) .unwrap(); assert_eq!(k2, 1); } #[test] fn mappings_persist_across_reopen() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ids.json"); let (a, b); { let s = JsonIdentityStore::open(&path).unwrap(); a = s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(); b = s.resolve_or_allocate(G, K, "oc-B", BASE).unwrap(); } // reopen: same ids, reverse intact, next allocation continues monotonic let s = JsonIdentityStore::open(&path).unwrap(); assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(a)); assert_eq!(s.external_for(G, K, "oc-B").unwrap(), Some(b)); assert_eq!(s.core_for(G, K, a).unwrap().as_deref(), Some("oc-A")); let c = s.resolve_or_allocate(G, K, "oc-C", BASE).unwrap(); assert_eq!(c, b + 1, "counter resumes after restart, no reuse"); } #[test] fn corrupt_reverse_duplicate_is_rejected_on_open() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ids.json"); // hand-write a file with two core ids sharing one external id in a scope std::fs::write( &path, r#"[{"game_id":"fifa17","entity_kind":"owned-item","core_id":"a","external_id":5}, {"game_id":"fifa17","entity_kind":"owned-item","core_id":"b","external_id":5}]"#, ) .unwrap(); assert!( JsonIdentityStore::open(&path).is_err(), "ambiguous reverse must not load" ); } #[test] fn insert_existing_mapping_preserves_and_is_idempotent() { let (s, _d) = store(); s.insert_existing_mapping(G, K, "oc-A", 100_004_600) .unwrap(); assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(100_004_600)); assert_eq!( s.core_for(G, K, 100_004_600).unwrap().as_deref(), Some("oc-A") ); // identical re-insert is idempotent s.insert_existing_mapping(G, K, "oc-A", 100_004_600) .unwrap(); } #[test] fn insert_existing_mapping_rejects_conflicts() { let (s, _d) = store(); s.insert_existing_mapping(G, K, "oc-A", 100_000_010) .unwrap(); // same core -> different external: reject assert!(matches!( s.insert_existing_mapping(G, K, "oc-A", 100_000_011), Err(IdError::Conflict(_)) )); // same external -> different core: reject assert!(matches!( s.insert_existing_mapping(G, K, "oc-B", 100_000_010), Err(IdError::Conflict(_)) )); } #[test] fn watermark_preserves_allocation_history_past_gaps() { let (s, _d) = store(); // a live mapping (highest surviving id) plus a source watermark that sits // ABOVE it because ids in between were issued then removed (burned gap). s.insert_existing_mapping(G, K, "oc-live", 100_004_605) .unwrap(); s.set_watermark(G, K, 100_004_617).unwrap(); assert_eq!(s.peek_next_external(G, K, BASE), 100_004_617); // first NEW allocation is the source next-to-issue, NOT live_max+1 assert_eq!( s.resolve_or_allocate(G, K, "oc-new", BASE).unwrap(), 100_004_617 ); // then continues monotonically assert_eq!( s.resolve_or_allocate(G, K, "oc-new2", BASE).unwrap(), 100_004_618 ); } #[test] fn watermark_and_imported_ids_survive_reopen() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ids.json"); { let s = JsonIdentityStore::open(&path).unwrap(); s.insert_existing_mapping(G, K, "oc-A", 100_000_001) .unwrap(); s.insert_existing_mapping(G, K, "oc-B", 100_004_605) .unwrap(); s.set_watermark(G, K, 100_004_617).unwrap(); } let s = JsonIdentityStore::open(&path).unwrap(); assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(100_000_001)); assert_eq!( s.core_for(G, K, 100_004_605).unwrap().as_deref(), Some("oc-B") ); assert_eq!(s.watermark_for(G, K), Some(100_004_617)); // sparse import: next allocation respects the persisted watermark assert_eq!( s.resolve_or_allocate(G, K, "oc-C", BASE).unwrap(), 100_004_617 ); } }