feat(identity): generic game-scoped external-identity store
openfut-identity: durable, reversible (game_id, entity_kind, core_id) <-> external wire id mapping. Game-independent infrastructure (adapters supply the numeric policy via base_floor; the store guarantees stable/unique/reversible/ game-scoped/persistent/atomic/explicit). JSON-file backed behind an ExternalIdentityStore trait (SQLite can drop in later); parking_lot-guarded, atomic temp+rename persist, rejects a torn reverse-duplicate on open. Core never learns FIFA integers; only the host/adapter that owns a game boundary uses this. 6 tests, 4/4 mutations killed (same-id-for-two-items, lost-on-restart, broken-reverse, dropped-game-scope). Phase commit 1/5.
This commit is contained in:
Generated
+10
@@ -3202,6 +3202,16 @@ dependencies = [
|
||||
name = "openfut-http"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-identity"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-launcher"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -11,6 +11,7 @@ members = [
|
||||
"openfut-redirector-host",
|
||||
"openfut-roster-host",
|
||||
"openfut-utas-host",
|
||||
"openfut-identity",
|
||||
"openfut-bridge",
|
||||
"openfut-launcher",
|
||||
"openfut-launcher/openfut-hook",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "openfut-identity"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Generic, game-scoped external-identity store: durable, reversible (GameId, EntityKind, CoreId) <-> external wire id. Game-independent infrastructure; adapters supply policy."
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Guards the in-memory index + atomic file persist; lock guards are unwrapped
|
||||
# directly (project rule: parking_lot over std::sync when immediately unwrapping).
|
||||
parking_lot = "0.12"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,337 @@
|
||||
//! # 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),
|
||||
}
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
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<i64, IdError>;
|
||||
|
||||
/// Existing external id for a core id, or `None` (never allocates).
|
||||
fn external_for(&self, game: &str, kind: &str, core_id: &str) -> Result<Option<i64>, IdError>;
|
||||
|
||||
/// Reverse lookup: the core id that owns `external_id`, or `None`.
|
||||
fn core_for(&self, game: &str, kind: &str, external_id: i64)
|
||||
-> Result<Option<String>, IdError>;
|
||||
}
|
||||
|
||||
/// One persisted mapping row.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Row {
|
||||
game_id: String,
|
||||
entity_kind: String,
|
||||
core_id: String,
|
||||
external_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
rows: Vec<Row>,
|
||||
fwd: HashMap<(String, String, String), i64>,
|
||||
rev: HashMap<(String, String, i64), String>,
|
||||
max: 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-file backed [`ExternalIdentityStore`]. Single-writer (one host process).
|
||||
pub struct JsonIdentityStore {
|
||||
path: PathBuf,
|
||||
inner: Mutex<Index>,
|
||||
}
|
||||
|
||||
impl JsonIdentityStore {
|
||||
/// Open (or create) the store at `path`, loading any existing mappings.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, IdError> {
|
||||
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() {
|
||||
let rows: Vec<Row> =
|
||||
serde_json::from_str(&raw).map_err(|e| IdError::Corrupt(e.to_string()))?;
|
||||
for row in 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(JsonIdentityStore {
|
||||
path,
|
||||
inner: Mutex::new(index),
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically persist the full row set (temp + rename).
|
||||
fn persist(&self, index: &Index) -> Result<(), IdError> {
|
||||
let json =
|
||||
serde_json::to_vec_pretty(&index.rows).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(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalIdentityStore for JsonIdentityStore {
|
||||
fn resolve_or_allocate(
|
||||
&self,
|
||||
game: &str,
|
||||
kind: &str,
|
||||
core_id: &str,
|
||||
base_floor: i64,
|
||||
) -> Result<i64, IdError> {
|
||||
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 = match ix.max.get(&scope) {
|
||||
Some(&m) => (m + 1).max(base_floor),
|
||||
None => 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<Option<i64>, 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<Option<String>, 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user