a51947562c
openfut-identity: - insert_existing_mapping(game,kind,core_id,external_id): preserve an existing external wire id instead of minting; idempotent for an identical mapping, rejects conflicting forward/reverse with IdError::Conflict, persists atomically. - persisted per-scope allocator watermark (set_watermark/watermark_for) so a future mint continues past the source high-water even across burned-id gaps; next id = max(base_floor, live_max+1, watermark). Backward-compatible on-disk format (legacy bare [Row] still loads). +4 tests (10 total). openfut-import-fifa17 (new): read-only dry-run analysis of a real FIFA17 Python profile for a faithful Core import. Enforces disjoint item-class balance; proposes profile-derived CardDefinitions keyed fifa17_<resourceId> (base vs versioned never collapse) with a resourceId-group consistency gate (hard-fail on disagreement, never pick a winner) and honest buildability (roster name + version formula + metadata, never fabricated); plans owned-instance identity (preserve Python wire ids, preserve nextItemId watermark); checks active-squad coverage. --apply/--emit-content refuse to write in this phase. 11 tests. Real profile (33068179/CAGE) dry-run: 1982 items balance (1962 players + 17 consumables + 3 staff); 1681 supported defs (155 base + 1535 versioned), 9 NoName unsupported, 1 hard conflict (resourceId 169193: one of 4 copies has a divergent nation/team/league); 1949 importable player instances, watermark 100004617 -> first new alloc 100004617; active squad f433 fully supported. fmt + clippy -D warnings clean.
582 lines
21 KiB
Rust
582 lines
21 KiB
Rust
//! # 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<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,
|
|
}
|
|
|
|
/// 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<Row>,
|
|
#[serde(default)]
|
|
watermarks: Vec<Watermark>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Index {
|
|
rows: Vec<Row>,
|
|
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<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() {
|
|
// 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::<StoreFile>(&raw) {
|
|
Ok(f) => f,
|
|
Err(_) => {
|
|
let rows: Vec<Row> = 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<i64> {
|
|
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<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 = 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<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"
|
|
);
|
|
}
|
|
|
|
#[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
|
|
);
|
|
}
|
|
}
|