//! The sqlite file behind everything server-side: OAuth state, OAuth //! sessions (which hold tokens — this file is a credential store first and a //! database second), and the account rows `/api/session` answers from. //! //! rusqlite is synchronous. Every operation runs the closure on a blocking //! thread over one mutex-serialized connection: the futures come out `Send` //! (the atrium store traits require it), no runtime worker parks on file //! I/O, and a single process writing a WAL database needs nothing cleverer. use std::collections::HashSet; use std::path::Path; use std::sync::{Arc, Mutex}; use rusqlite::Connection; #[derive(Debug)] pub enum DbError { Sqlite(rusqlite::Error), /// The blocking task was cancelled — only happens at shutdown. Cancelled, } impl std::fmt::Display for DbError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DbError::Sqlite(e) => write!(f, "sqlite: {e}"), DbError::Cancelled => write!(f, "database task cancelled"), } } } impl std::error::Error for DbError {} #[derive(Clone)] pub struct Db(Arc>); /// One row of the matches table. Status vocabulary lives in matches/mod.rs; /// here it is just text. #[derive(Clone, Debug)] pub struct MatchRow { pub id: String, pub owner_did: String, pub status: String, pub token: String, pub task_arn: Option, pub task_ip: Option, pub created_at: i64, /// The scenario path the match launched with. None on rows that predate /// the column. pub scenario: Option, /// When the match became playable, and when it was decided. Both None on /// rows that predate the columns, and `ended_at` is None while a match is /// still being fought. pub started_at: Option, pub ended_at: Option, } /// One seat in a match. `did` is NULL for a bot; `handle` is the display /// name the seat launched under — for a bot that is the site's own handle, /// and for a human it is what verified at launch time, kept as-was rather /// than re-resolved: it names who was fought, not who they are now. #[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct MatchPlayer { pub slot: String, pub control: String, pub did: Option, pub handle: Option, /// The team this seat fights on, when a lobby said something other than /// the scenario did. `None` is not "no team": it is "nobody overrode /// it", and the scenario's own `team_` stands — see /// `routes::lobby_seats_to_players`, which is where the two are resolved /// into the one number the manifest carries. pub team: Option, } /// A player seen across the table, for the challenge screen's recent list. #[derive(Clone, Debug, serde::Serialize)] pub struct Opponent { pub did: String, pub handle: Option, /// When the latest match against them started, unix seconds. #[serde(rename = "lastMatchAt")] pub last_match_at: i64, } /// Adds a column unless the table already has it. Table and column names are /// literals in this file, never input. fn add_column( conn: &Connection, table: &str, column: &str, declaration: &str, ) -> Result<(), rusqlite::Error> { let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?; let mut columns = statement.query_map([], |row| row.get::<_, String>(1))?; if columns.any(|name| name.as_deref() == Ok(column)) { return Ok(()); } conn.execute_batch(&format!( "ALTER TABLE {table} ADD COLUMN {column} {declaration}" )) } fn row_to_match(r: &rusqlite::Row<'_>) -> Result { Ok(MatchRow { id: r.get(0)?, owner_did: r.get(1)?, status: r.get(2)?, token: r.get(3)?, task_arn: r.get(4)?, task_ip: r.get(5)?, created_at: r.get(6)?, scenario: r.get(7)?, started_at: r.get(8)?, ended_at: r.get(9)?, }) } /// One player's attempt at one daily challenge. /// /// `did` and `day` are stored and read back but nothing acts on them yet: they /// are what a per-player history and a day's board are built from, which is /// the next half of this epic. #[derive(Debug, Clone)] #[allow(dead_code, reason = "did and day are for the board and the profile")] pub struct DailyAttempt { pub slug: String, pub did: String, pub match_id: String, /// The UTC day the attempt was started. pub day: String, /// None while the match is still running, or if it never produced a /// result — though in that case the row is deleted rather than kept. pub score: Option, } fn daily_attempt_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(DailyAttempt { slug: row.get(0)?, did: row.get(1)?, match_id: row.get(2)?, day: row.get(3)?, score: row.get(4)?, }) } impl Db { pub fn open(path: &Path) -> Result { if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent) .map_err(|e| format!("cannot create {}: {e}", parent.display()))?; } let conn = Connection::open(path).map_err(|e| format!("cannot open {}: {e}", path.display()))?; // Tokens at rest: nobody but the service user gets to read them. let mut permissions = std::fs::metadata(path) .map_err(|e| format!("cannot stat {}: {e}", path.display()))? .permissions(); std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o600); std::fs::set_permissions(path, permissions) .map_err(|e| format!("cannot chmod {}: {e}", path.display()))?; conn.execute_batch( "PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS oauth_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS oauth_session ( did TEXT PRIMARY KEY, value TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS account ( did TEXT PRIMARY KEY, handle TEXT, verified_at INTEGER, checked_at INTEGER ); CREATE TABLE IF NOT EXISTS matches ( id TEXT PRIMARY KEY, owner_did TEXT NOT NULL, status TEXT NOT NULL, token TEXT NOT NULL, task_arn TEXT, task_ip TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS match_players ( match_id TEXT NOT NULL, slot TEXT NOT NULL, control TEXT NOT NULL, did TEXT, handle TEXT, PRIMARY KEY (match_id, slot) ); CREATE INDEX IF NOT EXISTS match_players_by_did ON match_players (did) WHERE did IS NOT NULL; -- One attempt at a daily challenge, per player per challenge. -- The primary key is the rule: there is no second row to write, -- so a second attempt fails at the database rather than at a -- check somebody can forget to make. -- -- A row is written when the match launches and carries the -- player through it. `score` is null while the match runs, and -- the row is *deleted* if the match ends without a result - -- an attempt lost to a crash is not a score, and burning it -- would charge the player for our own failure. CREATE TABLE IF NOT EXISTS daily_attempt ( slug TEXT NOT NULL, did TEXT NOT NULL, match_id TEXT NOT NULL, day TEXT NOT NULL, score INTEGER, bv_start INTEGER, bv_left INTEGER, foe_bv_start INTEGER, foe_bv_left INTEGER, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (slug, did) ); CREATE UNIQUE INDEX IF NOT EXISTS daily_attempt_by_match ON daily_attempt (match_id);", ) .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; // The batch above only creates missing tables, so a database that // predates a column keeps it missing. sqlite has no ADD COLUMN IF NOT // EXISTS; asking the table what it has is the portable substitute. add_column(&conn, "account", "checked_at", "INTEGER") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; add_column(&conn, "account", "default_camo", "TEXT") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; add_column(&conn, "match_players", "team", "INTEGER") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; add_column(&conn, "matches", "scenario", "TEXT") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; // When the match became playable and when it was decided. Stamped by // set_match_status, so nothing has to remember to do it. add_column(&conn, "matches", "started_at", "INTEGER") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; add_column(&conn, "matches", "ended_at", "INTEGER") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; Ok(Db(Arc::new(Mutex::new(conn)))) } /// Runs `f` against the connection on a blocking thread. pub async fn call(&self, f: F) -> Result where T: Send + 'static, F: FnOnce(&Connection) -> Result + Send + 'static, { let db = self.0.clone(); tokio::task::spawn_blocking(move || { let conn = db.lock().expect("db mutex poisoned"); f(&conn).map_err(DbError::Sqlite) }) .await .map_err(|_| DbError::Cancelled)? } /// The one writer of the account row, used by sign-in and by every later /// re-check. `handle` is the *verified* handle — None means verification /// failed and the UI shows the DID as unverified, so a name that stops /// verifying is demoted by the same path that promoted it. /// /// `verified_at` is when the handle last checked out; `checked_at` is /// when we last looked, however it went. They differ for exactly the /// account that is failing verification, which is the one that still /// needs scheduling for another look. pub async fn record_handle_check( &self, did: &str, handle: Option<&str>, ) -> Result<(), DbError> { let did = did.to_owned(); let handle = handle.map(str::to_owned); self.call(move |conn| { conn.execute( "INSERT INTO account (did, handle, verified_at, checked_at) VALUES (?1, ?2, CASE WHEN ?2 IS NULL THEN NULL ELSE unixepoch() END, unixepoch()) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, verified_at = excluded.verified_at, checked_at = excluded.checked_at", (did, handle), ) .map(|_| ()) }) .await } /// Takes the right to re-verify this account's handle, if it has not been /// looked at since `stale_before`. True means the caller got it and /// nobody else will until the next interval; false means the row is /// fresh, or another request claimed it first. /// /// The claim is the `checked_at` bump itself, which is why it is written /// before the work rather than after: a compare-and-swap in the shared /// store needs no coordination between requests, or between instances if /// there is ever more than one. The cost of writing it early is that a /// crash mid-check delays the re-check by one interval, and a delayed /// handle refresh is not worth a lock to avoid. pub async fn claim_handle_check(&self, did: &str, stale_before: i64) -> Result { let did = did.to_owned(); self.call(move |conn| { conn.execute( "UPDATE account SET checked_at = unixepoch() WHERE did = ?1 AND (checked_at IS NULL OR checked_at < ?2)", (did, stale_before), ) .map(|rows| rows == 1) }) .await } /// `None` — no such account; `Some(None)` — account with no verified /// handle. pub async fn account_handle(&self, did: &str) -> Result>, DbError> { let did = did.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row("SELECT handle FROM account WHERE did = ?1", [did], |row| { row.get::<_, Option>(0) }) .optional() }) .await } /// The camo a match uses when the player does not pick one, as the record /// key of a `blue.lance.camo` in their own repository. /// /// A site preference and not player content, which is why it is here and /// not in their repo: the camo itself is theirs and lives in their /// account, and this is only lance.blue remembering which one to reach for /// first. It is a key and not a copy, so a camo renamed, redrawn or /// deleted needs nothing done here — a key that no longer resolves is read /// as "no default", which is the same as never having set one. pub async fn default_camo(&self, did: &str) -> Result, DbError> { let did = did.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT default_camo FROM account WHERE did = ?1", [did], |row| row.get::<_, Option>(0), ) .optional() .map(Option::flatten) }) .await } /// Sets it, or clears it with None. /// /// Upserts, because the account row is created by the handle check and a /// player can reach this before one has ever run. pub async fn set_default_camo(&self, did: &str, rkey: Option<&str>) -> Result<(), DbError> { let did = did.to_owned(); let rkey = rkey.map(str::to_owned); self.call(move |conn| { conn.execute( "INSERT INTO account (did, default_camo) VALUES (?1, ?2) ON CONFLICT(did) DO UPDATE SET default_camo = ?2", rusqlite::params![did, rkey], ) .map(|_| ()) }) .await } /// Whether a stored OAuth session still exists for the DID. The cookie /// alone is not a session: this row going away is how sign-out and a /// wiped database actually sign someone out. pub async fn has_oauth_session(&self, did: &str) -> Result { let did = did.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row("SELECT 1 FROM oauth_session WHERE did = ?1", [did], |_| { Ok(()) }) .optional() .map(|row| row.is_some()) }) .await } pub async fn insert_match(&self, row: &MatchRow) -> Result<(), DbError> { let row = row.clone(); self.call(move |conn| { conn.execute( "INSERT INTO matches (id, owner_did, status, token, scenario) VALUES (?1, ?2, ?3, ?4, ?5)", (row.id, row.owner_did, row.status, row.token, row.scenario), ) .map(|_| ()) }) .await } /// Writes a match's seats, all at once at launch. They never change /// afterwards: who was in a match is a fact about the launch. pub async fn insert_match_players( &self, match_id: &str, players: &[MatchPlayer], ) -> Result<(), DbError> { let match_id = match_id.to_owned(); let players = players.to_vec(); self.call(move |conn| { let mut statement = conn.prepare( "INSERT INTO match_players (match_id, slot, control, did, handle, team) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", )?; for p in &players { statement.execute((&match_id, &p.slot, &p.control, &p.did, &p.handle, &p.team))?; } Ok(()) }) .await } /// Claims one seat for a lobby that fills in one slot at a time, rather /// than the whole slate at once the way `insert_match_players` does for /// a launch. Writes `player`'s row only if `player.slot` has no occupant /// yet, and — when `player.did` is `Some` — only if that DID does not /// already hold a *different* slot in the same match; a bot's `did` is /// always `None`, so several bot seats never trip that second check. /// /// This is deliberately not a blind "INSERT OR REPLACE": that would make /// the *last* claim of a contested slot win, and a lobby seat is meant /// to be first-write-wins. Both conditions are evaluated by the same /// `INSERT ... WHERE NOT EXISTS`, so there is no gap between "is this /// slot free" and "take it" for a second, concurrent call to land in — /// unlike a `match_players` read followed by a separate write, which /// would race even though every `Db` call funnels through one connection, /// because the read and the write would be two separate lock /// acquisitions with room for another caller's write in between. /// /// Returns whether this call's row is the one that landed. pub async fn claim_match_player( &self, match_id: &str, player: &MatchPlayer, ) -> Result { let match_id = match_id.to_owned(); let player = player.clone(); self.call(move |conn| { let changed = conn.execute( "INSERT INTO match_players (match_id, slot, control, did, handle, team) SELECT ?1, ?2, ?3, ?4, ?5, ?6 WHERE NOT EXISTS ( SELECT 1 FROM match_players WHERE match_id = ?1 AND slot = ?2 ) AND ( ?4 IS NULL OR NOT EXISTS ( SELECT 1 FROM match_players WHERE match_id = ?1 AND did = ?4 ) )", ( &match_id, &player.slot, &player.control, &player.did, &player.handle, &player.team, ), )?; Ok(changed == 1) }) .await } /// Sets (or clears) one seat's team override. Clearing — `None` — is /// not "this seat has no team": it hands the question back to the /// scenario file, which is where the answer came from before a lobby /// had an opinion. /// /// Returns whether a row was actually written, so a caller can tell a /// change from a request naming a slot nobody holds. pub async fn set_match_player_team( &self, match_id: &str, slot: &str, team: Option, ) -> Result { let (match_id, slot) = (match_id.to_owned(), slot.to_owned()); self.call(move |conn| { let changed = conn.execute( "UPDATE match_players SET team = ?3 WHERE match_id = ?1 AND slot = ?2", (match_id, slot, team), )?; Ok(changed == 1) }) .await } /// Hands a lobby to a different owner. Lobby rows only: a launched /// match's owner is a fact about who launched it. pub async fn set_match_owner(&self, match_id: &str, owner_did: &str) -> Result<(), DbError> { let (match_id, owner_did) = (match_id.to_owned(), owner_did.to_owned()); self.call(move |conn| { conn.execute( "UPDATE matches SET owner_did = ?2, updated_at = unixepoch() WHERE id = ?1 AND status = 'lobby'", (match_id, owner_did), ) .map(|_| ()) }) .await } /// Removes one seat. The only way a claimed seat disappears in this /// slice: a lobby's scenario changes out from under it and the slot the /// seat was in no longer exists in the new scenario. There is no /// "leave the lobby" action yet. pub async fn delete_match_player(&self, match_id: &str, slot: &str) -> Result<(), DbError> { let (match_id, slot) = (match_id.to_owned(), slot.to_owned()); self.call(move |conn| { conn.execute( "DELETE FROM match_players WHERE match_id = ?1 AND slot = ?2", (match_id, slot), ) .map(|_| ()) }) .await } /// The seats of one match, in slot order. pub async fn match_players(&self, match_id: &str) -> Result, DbError> { let match_id = match_id.to_owned(); self.call(move |conn| { let mut statement = conn.prepare( "SELECT slot, control, did, handle, team FROM match_players WHERE match_id = ?1 ORDER BY rowid", )?; let rows = statement.query_map([match_id], |r| { Ok(MatchPlayer { slot: r.get(0)?, control: r.get(1)?, did: r.get(2)?, handle: r.get(3)?, team: r.get(4)?, }) })?; rows.collect() }) .await } /// Whether this DID holds a seat in this match. The proxy's question: /// matches that predate the seats table have only their owner row, so /// callers check the owner column first. pub async fn is_match_player(&self, match_id: &str, did: &str) -> Result { let (match_id, did) = (match_id.to_owned(), did.to_owned()); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT 1 FROM match_players WHERE match_id = ?1 AND did = ?2", (match_id, did), |_| Ok(()), ) .optional() .map(|row| row.is_some()) }) .await } /// Whether this DID fights in this match — holds a human seat, not just /// a view of it. What routing keys on: the container's client for a /// fighter lives at `/play/`. pub async fn is_human_player(&self, match_id: &str, did: &str) -> Result { let (match_id, did) = (match_id.to_owned(), did.to_owned()); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT 1 FROM match_players WHERE match_id = ?1 AND did = ?2 AND control = 'human'", (match_id, did), |_| Ok(()), ) .optional() .map(|row| row.is_some()) }) .await } /// Everyone this DID has shared a match with, latest first. Bots have no /// DID and drop out on their own; the handle is the one from the most /// recent match, which is the name they were last seen under — sqlite's /// documented bare-column-with-max() behavior is what makes `other.handle` /// come from the row that won the MAX. pub async fn recent_opponents(&self, did: &str, limit: u32) -> Result, DbError> { let did = did.to_owned(); self.call(move |conn| { let mut statement = conn.prepare( "SELECT other.did, other.handle, MAX(m.created_at) AS last FROM match_players mine JOIN match_players other ON other.match_id = mine.match_id JOIN matches m ON m.id = mine.match_id WHERE mine.did = ?1 AND other.did IS NOT NULL AND other.did != ?1 GROUP BY other.did ORDER BY last DESC LIMIT ?2", )?; let rows = statement.query_map((did, limit), |r| { Ok(Opponent { did: r.get(0)?, handle: r.get(1)?, last_match_at: r.get(2)?, }) })?; rows.collect() }) .await } /// Everyone this DID has ever shared a match with, unordered and /// uncapped — recent_opponents' join, minus the ordering and limit that /// make sense for a "recent" strip but not for a full exclusion set. /// /// Used to leave already-faced opponents out of suggestions: a followed /// account already fought is a re-sort of the recent-opponents strip, /// not new discovery. pub async fn all_opponent_dids(&self, did: &str) -> Result, DbError> { let did = did.to_owned(); self.call(move |conn| { let mut statement = conn.prepare( "SELECT DISTINCT other.did FROM match_players mine JOIN match_players other ON other.match_id = mine.match_id WHERE mine.did = ?1 AND other.did IS NOT NULL AND other.did != ?1", )?; let rows = statement.query_map([&did], |r| r.get::<_, String>(0))?; rows.collect() }) .await } /// Which of `dids` have ever held a seat in any match, anywhere — "has /// played lance.blue before," scoped to a candidate list rather than the /// whole table: match_players has no upper bound, and every caller of /// this only ever wants an answer about a bounded follow-list page. pub async fn played_dids(&self, dids: &[String]) -> Result, DbError> { if dids.is_empty() { return Ok(HashSet::new()); } let dids = dids.to_vec(); self.call(move |conn| { let placeholders = vec!["?"; dids.len()].join(","); let sql = format!("SELECT DISTINCT did FROM match_players WHERE did IN ({placeholders})"); let mut statement = conn.prepare(&sql)?; let rows = statement.query_map(rusqlite::params_from_iter(dids.iter()), |r| { r.get::<_, String>(0) })?; rows.collect() }) .await } pub async fn get_match(&self, id: &str) -> Result, DbError> { let id = id.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT id, owner_did, status, token, task_arn, task_ip, created_at, scenario, started_at, ended_at FROM matches WHERE id = ?1", [id], row_to_match, ) .optional() }) .await } pub async fn set_match_status(&self, id: &str, status: &str) -> Result<(), DbError> { let (id, status) = (id.to_owned(), status.to_owned()); self.call(move |conn| { // The two timestamps are stamped here rather than by the // callers, because every path that ends a match goes through this // one statement and none of them would remember. First write // wins: a status re-asserted must not move the clock. conn.execute( "UPDATE matches SET status = ?2, updated_at = unixepoch(), started_at = CASE WHEN started_at IS NULL AND ?2 IN ('running', 'ready') THEN unixepoch() ELSE started_at END, ended_at = CASE WHEN ended_at IS NULL AND ?2 IN ('over', 'failed') THEN unixepoch() ELSE ended_at END WHERE id = ?1", (id, status), ) .map(|_| ()) }) .await } /// How many players have taken on a challenge. Attempts, not finishers: /// a match still running counts, because the card's figure is "how many /// people have tried this" and a player mid-match has. pub async fn daily_attempt_count(&self, slug: &str) -> Result { let slug = slug.to_owned(); self.call(move |conn| { conn.query_row( "SELECT COUNT(*) FROM daily_attempt WHERE slug = ?1", [slug], |row| row.get(0), ) }) .await } /// This player's attempt at this challenge, if they have used it. pub async fn daily_attempt( &self, slug: &str, did: &str, ) -> Result, DbError> { let (slug, did) = (slug.to_owned(), did.to_owned()); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT slug, did, match_id, day, score FROM daily_attempt WHERE slug = ?1 AND did = ?2", (slug, did), daily_attempt_row, ) .optional() }) .await } /// The attempt a match belongs to, if it is one. Used when a match ends, /// to decide whether there is a score to write or an attempt to release. pub async fn daily_attempt_for_match( &self, match_id: &str, ) -> Result, DbError> { let match_id = match_id.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT slug, did, match_id, day, score FROM daily_attempt WHERE match_id = ?1", [match_id], daily_attempt_row, ) .optional() }) .await } /// Take the player's one attempt at a challenge. /// /// False means they had already used it. The primary key is what decides /// that, not a read followed by a write: two launches racing each other /// would both pass a check and only one can insert. pub async fn take_daily_attempt( &self, slug: &str, did: &str, match_id: &str, day: &str, ) -> Result { let (slug, did, match_id, day) = ( slug.to_owned(), did.to_owned(), match_id.to_owned(), day.to_owned(), ); self.call(move |conn| { let changed = conn.execute( "INSERT OR IGNORE INTO daily_attempt (slug, did, match_id, day) VALUES (?1, ?2, ?3, ?4)", (slug, did, match_id, day), )?; Ok(changed > 0) }) .await } /// Point a claimed attempt at the match it was spent on, replacing the /// placeholder the claim was made against. pub async fn bind_daily_attempt( &self, slug: &str, did: &str, match_id: &str, ) -> Result<(), DbError> { let (slug, did, match_id) = (slug.to_owned(), did.to_owned(), match_id.to_owned()); self.call(move |conn| { conn.execute( "UPDATE daily_attempt SET match_id = ?3, updated_at = unixepoch() WHERE slug = ?1 AND did = ?2", (slug, did, match_id), ) .map(|_| ()) }) .await } /// Write the score for a finished attempt. pub async fn score_daily_attempt( &self, match_id: &str, score: i64, mine: (i64, i64), theirs: (i64, i64), ) -> Result<(), DbError> { let match_id = match_id.to_owned(); self.call(move |conn| { conn.execute( "UPDATE daily_attempt SET score = ?2, bv_start = ?3, bv_left = ?4, foe_bv_start = ?5, foe_bv_left = ?6, updated_at = unixepoch() WHERE match_id = ?1", (match_id, score, mine.0, mine.1, theirs.0, theirs.1), ) .map(|_| ()) }) .await } /// Give an attempt back, because the match it was spent on produced no /// result. Deleting the row is what makes the attempt available again, /// and it is a no-op for a match that was never a daily attempt. /// /// Only an unscored attempt is released: a late duplicate `error` event /// after a match that already scored must not take the score away. pub async fn release_daily_attempt(&self, match_id: &str) -> Result { let match_id = match_id.to_owned(); self.call(move |conn| { let changed = conn.execute( "DELETE FROM daily_attempt WHERE match_id = ?1 AND score IS NULL", [match_id], )?; Ok(changed > 0) }) .await } pub async fn set_match_task(&self, id: &str, task_arn: &str) -> Result<(), DbError> { let (id, task_arn) = (id.to_owned(), task_arn.to_owned()); self.call(move |conn| { conn.execute( "UPDATE matches SET task_arn = ?2, updated_at = unixepoch() WHERE id = ?1", (id, task_arn), ) .map(|_| ()) }) .await } pub async fn set_match_ip(&self, id: &str, task_ip: &str) -> Result<(), DbError> { let (id, task_ip) = (id.to_owned(), task_ip.to_owned()); self.call(move |conn| { conn.execute( "UPDATE matches SET task_ip = ?2, updated_at = unixepoch() WHERE id = ?1", (id, task_ip), ) .map(|_| ()) }) .await } /// The one write a `lobby`-status row takes while it is still a lobby: /// swapping which scenario it is parked on. `matches::lobby::Lobbies` /// calls this only after checking the path against the catalog itself. pub async fn set_match_scenario(&self, id: &str, scenario: &str) -> Result<(), DbError> { let (id, scenario) = (id.to_owned(), scenario.to_owned()); self.call(move |conn| { conn.execute( "UPDATE matches SET scenario = ?2, updated_at = unixepoch() WHERE id = ?1", (id, scenario), ) .map(|_| ()) }) .await } /// The atomic guard against two callers deploying the same lobby at /// once: flips the row from `lobby` to `deploying` only if it is still /// `lobby` when this runs. `routes.rs`'s `deploy_lobby` calls this /// before ever calling `Matches::start`, so a second caller — a second /// click, a second browser — loses here rather than after a second /// match has already been minted. Same shape as `claim_match_player`'s /// `WHERE NOT EXISTS`: one write decides the race, with no gap between /// "is this still a lobby" and "claim it" for a second caller to land /// in. /// /// Returns whether this call's write is the one that landed. pub async fn claim_deploy(&self, id: &str) -> Result { let id = id.to_owned(); self.call(move |conn| { let changed = conn.execute( "UPDATE matches SET status = 'deploying', updated_at = unixepoch() WHERE id = ?1 AND status = 'lobby'", [id], )?; Ok(changed == 1) }) .await } /// Removes a lobby's own row and its seats, once it has resolved into a /// real match and there is nothing left to keep it around for. There is /// no foreign key or `ON DELETE CASCADE` between `matches` and /// `match_players` (see the `CREATE TABLE` statements above), so both /// are deleted here explicitly, in the same call. pub async fn delete_lobby(&self, id: &str) -> Result<(), DbError> { let id = id.to_owned(); self.call(move |conn| { conn.execute("DELETE FROM match_players WHERE match_id = ?1", [&id])?; conn.execute("DELETE FROM matches WHERE id = ?1", [&id])?; Ok(()) }) .await } /// Matches this DID is in — as the launcher, or holding a seat someone /// else's launch gave them. The seat is what puts an invited player's /// match in their own list, which is how they find it to join it. /// /// `lobby`-status rows are excluded: they are not a match yet, and this /// is the query "Your matches" reads. pub async fn list_matches( &self, owner_did: &str, limit: u32, ) -> Result, DbError> { let owner_did = owner_did.to_owned(); self.call(move |conn| { let mut statement = conn.prepare( "SELECT id, owner_did, status, token, task_arn, task_ip, created_at, scenario, started_at, ended_at FROM matches WHERE status != 'lobby' AND (owner_did = ?1 OR id IN (SELECT match_id FROM match_players WHERE did = ?1)) ORDER BY created_at DESC LIMIT ?2", )?; let rows = statement.query_map((owner_did, limit), row_to_match)?; rows.collect() }) .await } /// How many matches this account has been in, by our own record of them. /// /// The same predicate `list_matches` uses, counted rather than listed: /// the figure a player's card carries does not need the rows. Lobbies are /// excluded there and here — a lobby somebody opened and left is not a /// match anybody played. pub async fn match_count(&self, did: &str) -> Result { let did = did.to_owned(); self.call(move |conn| { conn.query_row( "SELECT COUNT(*) FROM matches WHERE status != 'lobby' AND (owner_did = ?1 OR id IN (SELECT match_id FROM match_players WHERE did = ?1))", [did], |row| row.get(0), ) }) .await } pub async fn delete_oauth_session(&self, did: &str) -> Result<(), DbError> { let did = did.to_owned(); self.call(move |conn| { conn.execute("DELETE FROM oauth_session WHERE did = ?1", [did]) .map(|_| ()) }) .await } } #[cfg(test)] mod tests { use super::*; async fn temp_db() -> (tempfile::TempDir, Db) { let dir = tempfile::tempdir().unwrap(); let db = Db::open(&dir.path().join("test.sqlite")).unwrap(); (dir, db) } async fn checked_at(db: &Db, did: &str) -> Option { let did = did.to_owned(); db.call(move |conn| { conn.query_row( "SELECT checked_at FROM account WHERE did = ?1", [did], |r| r.get(0), ) }) .await .unwrap() } #[tokio::test] async fn account_round_trip() { let (_dir, db) = temp_db().await; assert_eq!(db.account_handle("did:plc:abc").await.unwrap(), None); db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); assert_eq!( db.account_handle("did:plc:abc").await.unwrap(), Some(Some("someone.example".to_owned())) ); // A later check that fails verification demotes the handle. db.record_handle_check("did:plc:abc", None).await.unwrap(); assert_eq!(db.account_handle("did:plc:abc").await.unwrap(), Some(None)); } async fn seed_seats(db: &Db, match_id: &str, dids: &[&str]) { db.insert_match(&MatchRow { id: match_id.into(), owner_did: dids[0].into(), status: "over".into(), token: format!("tok-{match_id}"), task_arn: None, task_ip: None, created_at: 0, scenario: None, started_at: None, ended_at: None, }) .await .unwrap(); let players = dids .iter() .enumerate() .map(|(i, did)| MatchPlayer { slot: format!("Slot{i}"), control: "human".into(), did: Some((*did).to_owned()), handle: None, team: None, }) .collect::>(); db.insert_match_players(match_id, &players).await.unwrap(); } /// A `lobby`-status row is not a match yet, so "Your matches" must not /// list it — even when the caller owns it. #[tokio::test] async fn list_matches_excludes_lobby_status() { let (_dir, db) = temp_db().await; seed_seats(&db, "m1", &["did:plc:abc"]).await; // status "over" db.insert_match(&MatchRow { id: "m2".into(), owner_did: "did:plc:abc".into(), status: "lobby".into(), token: "tok-m2".into(), task_arn: None, task_ip: None, created_at: 0, scenario: Some("TrainingScenarios/1-FirstRun.mms".into()), started_at: None, ended_at: None, }) .await .unwrap(); let listed = db.list_matches("did:plc:abc", 20).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].id, "m1"); } #[tokio::test] async fn set_match_scenario_updates_the_row() { let (_dir, db) = temp_db().await; db.insert_match(&MatchRow { id: "m1".into(), owner_did: "did:plc:abc".into(), status: "lobby".into(), token: "tok".into(), task_arn: None, task_ip: None, created_at: 0, scenario: Some("TrainingScenarios/1-FirstRun.mms".into()), started_at: None, ended_at: None, }) .await .unwrap(); db.set_match_scenario("m1", "TrainingScenarios/2-FinalExam.mms") .await .unwrap(); let row = db.get_match("m1").await.unwrap().unwrap(); assert_eq!( row.scenario.as_deref(), Some("TrainingScenarios/2-FinalExam.mms") ); } fn seat(slot: &str, control: &str, did: Option<&str>) -> MatchPlayer { MatchPlayer { slot: slot.into(), control: control.into(), did: did.map(str::to_owned), handle: None, team: None, } } async fn lobby_row(db: &Db, id: &str) { db.insert_match(&MatchRow { id: id.into(), owner_did: "did:plc:owner".into(), status: "lobby".into(), token: format!("tok-{id}"), task_arn: None, task_ip: None, created_at: 0, scenario: Some("TrainingScenarios/1-FirstRun.mms".into()), started_at: None, ended_at: None, }) .await .unwrap(); } #[tokio::test] async fn claim_match_player_takes_an_empty_slot() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; let claimed = db .claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap(); assert!(claimed); let seats = db.match_players("m1").await.unwrap(); assert_eq!(seats.len(), 1); assert_eq!(seats[0].slot, "TraineeA"); assert_eq!(seats[0].did.as_deref(), Some("did:plc:abc")); } #[tokio::test] async fn claim_match_player_refuses_an_occupied_slot() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; db.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap(); // A different DID trying the same slot loses, even though the slot // has no relation to theirs. let claimed = db .claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:rival"))) .await .unwrap(); assert!(!claimed); let seats = db.match_players("m1").await.unwrap(); assert_eq!(seats.len(), 1); assert_eq!(seats[0].did.as_deref(), Some("did:plc:abc")); } #[tokio::test] async fn claim_match_player_refuses_a_second_slot_for_the_same_did() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; db.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap(); let claimed = db .claim_match_player("m1", &seat("TraineeB", "human", Some("did:plc:abc"))) .await .unwrap(); assert!(!claimed, "one DID must not hold two seats in one lobby"); assert_eq!(db.match_players("m1").await.unwrap().len(), 1); } /// Bots have no DID, so the "one seat per DID" guard must not catch /// them — several bot seats in one lobby is ordinary, not a race. #[tokio::test] async fn claim_match_player_allows_several_bot_seats() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; assert!( db.claim_match_player("m1", &seat("TraineeA", "bot", None)) .await .unwrap() ); assert!( db.claim_match_player("m1", &seat("TraineeB", "bot", None)) .await .unwrap() ); assert_eq!(db.match_players("m1").await.unwrap().len(), 2); } /// The concurrency claim the module doc makes: two callers racing for /// the same slot cannot both win. Spawned as real concurrent tokio /// tasks (not sequential awaits) so this exercises the actual race — /// both tasks reach `claim_match_player` before either's write lands — /// rather than just asserting the second of two sequential calls loses, /// which a check-then-write implementation could also pass by accident. /// /// Repeated: this test's own multi-threaded runtime tends to run the /// blocking `spawn_blocking` work in submission order, so a single /// iteration would not distinguish "atomic" from "lucky ordering". Every /// iteration still must land on exactly one winner and one row — the /// invariant this test exists to prove, whichever task happens to win. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn claim_match_player_is_first_write_wins_under_real_concurrency() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; for _ in 0..25 { db.call(|conn| conn.execute("DELETE FROM match_players", [])) .await .unwrap(); let db_a = db.clone(); let db_b = db.clone(); let a = tokio::spawn(async move { db_a.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:a"))) .await .unwrap() }); let b = tokio::spawn(async move { db_b.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:b"))) .await .unwrap() }); let (a_won, b_won) = tokio::join!(a, b); let (a_won, b_won) = (a_won.unwrap(), b_won.unwrap()); assert_ne!(a_won, b_won, "exactly one of two racing claims must win"); assert_eq!(db.match_players("m1").await.unwrap().len(), 1); } } #[tokio::test] async fn delete_match_player_removes_the_seat() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; db.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap(); db.delete_match_player("m1", "TraineeA").await.unwrap(); assert_eq!(db.match_players("m1").await.unwrap(), Vec::new()); } #[tokio::test] async fn claim_deploy_flips_a_lobby_to_deploying() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; assert!(db.claim_deploy("m1").await.unwrap()); let row = db.get_match("m1").await.unwrap().unwrap(); assert_eq!(row.status, "deploying"); } /// A row already past `lobby` — mid-deploy, or any other status — is not /// a second chance to claim it. Only the caller that finds it still at /// `lobby` wins. #[tokio::test] async fn claim_deploy_refuses_a_row_that_is_not_a_lobby() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; assert!(db.claim_deploy("m1").await.unwrap()); assert!(!db.claim_deploy("m1").await.unwrap()); let row = db.get_match("m1").await.unwrap().unwrap(); assert_eq!( row.status, "deploying", "still stuck where the first call left it" ); } /// The concurrency claim the doc comment makes: two callers racing to /// deploy the same lobby cannot both win. Real concurrent tokio tasks, /// not sequential awaits — the same reasoning as /// `claim_match_player_is_first_write_wins_under_real_concurrency` /// above: a check-then-write implementation could also pass a single /// sequential test by accident. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn claim_deploy_is_first_write_wins_under_real_concurrency() { let (_dir, db) = temp_db().await; for i in 0..25 { let id = format!("m{i}"); lobby_row(&db, &id).await; let (db_a, db_b) = (db.clone(), db.clone()); let (id_a, id_b) = (id.clone(), id.clone()); let a = tokio::spawn(async move { db_a.claim_deploy(&id_a).await.unwrap() }); let b = tokio::spawn(async move { db_b.claim_deploy(&id_b).await.unwrap() }); let (a_won, b_won) = tokio::join!(a, b); let (a_won, b_won) = (a_won.unwrap(), b_won.unwrap()); assert_ne!(a_won, b_won, "exactly one of two racing deploys must win"); assert_eq!( db.get_match(&id).await.unwrap().unwrap().status, "deploying" ); } } #[tokio::test] async fn delete_lobby_removes_the_row_and_its_seats() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; db.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap(); db.delete_lobby("m1").await.unwrap(); assert!(db.get_match("m1").await.unwrap().is_none()); assert_eq!(db.match_players("m1").await.unwrap(), Vec::new()); } #[tokio::test] async fn all_opponent_dids_is_unordered_and_uncapped() { let (_dir, db) = temp_db().await; seed_seats(&db, "m1", &["did:plc:abc", "did:plc:rival"]).await; seed_seats(&db, "m2", &["did:plc:abc", "did:plc:other"]).await; let opponents = db.all_opponent_dids("did:plc:abc").await.unwrap(); assert_eq!( opponents, HashSet::from(["did:plc:rival".to_owned(), "did:plc:other".to_owned()]) ); // Self is never its own opponent. assert!(!opponents.contains("did:plc:abc")); } #[tokio::test] async fn played_dids_answers_only_for_the_candidates_given() { let (_dir, db) = temp_db().await; seed_seats(&db, "m1", &["did:plc:abc", "did:plc:rival"]).await; assert_eq!( db.played_dids(&["did:plc:rival".to_owned(), "did:plc:nobody".to_owned()]) .await .unwrap(), HashSet::from(["did:plc:rival".to_owned()]) ); assert_eq!(db.played_dids(&[]).await.unwrap(), HashSet::new()); } #[tokio::test] async fn a_check_records_when_it_happened_either_way() { let (_dir, db) = temp_db().await; db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); assert!(checked_at(&db, "did:plc:abc").await.is_some()); // The failing case is the one that matters: without a clock of its // own an unverified account could never be scheduled for another // look, so it could never be promoted back. db.record_handle_check("did:plc:abc", None).await.unwrap(); assert!(checked_at(&db, "did:plc:abc").await.is_some()); } /// Backdates the last check to the given epoch second. async fn backdate_check(db: &Db, did: &str, to: i64) { let did = did.to_owned(); db.call(move |conn| { conn.execute( "UPDATE account SET checked_at = ?2 WHERE did = ?1", (did, to), ) .map(|_| ()) }) .await .unwrap(); } #[tokio::test] async fn only_one_claim_per_interval() { let (_dir, db) = temp_db().await; // No account row at all: nothing to claim. assert!(!db.claim_handle_check("did:plc:abc", 2).await.unwrap()); db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); assert!( !db.claim_handle_check("did:plc:abc", 2).await.unwrap(), "just checked, so not stale" ); // Stale: the first caller takes it, and the bump it writes is what // shuts the second one out. backdate_check(&db, "did:plc:abc", 1).await; assert!(db.claim_handle_check("did:plc:abc", 2).await.unwrap()); assert!(!db.claim_handle_check("did:plc:abc", 2).await.unwrap()); } /// The account row is written by the handle check, and a player can set a /// default before one has ever run for them. #[tokio::test] async fn a_default_camo_can_be_set_before_the_account_row_exists() { let (_dir, db) = temp_db().await; assert_eq!(db.default_camo("did:plc:abc").await.unwrap(), None); db.set_default_camo("did:plc:abc", Some("3mshoxhvtgt2f")) .await .unwrap(); assert_eq!( db.default_camo("did:plc:abc").await.unwrap(), Some("3mshoxhvtgt2f".to_owned()) ); // And a later handle check must not wipe it. db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); assert_eq!( db.default_camo("did:plc:abc").await.unwrap(), Some("3mshoxhvtgt2f".to_owned()) ); db.set_default_camo("did:plc:abc", None).await.unwrap(); assert_eq!(db.default_camo("did:plc:abc").await.unwrap(), None); assert_eq!( db.account_handle("did:plc:abc").await.unwrap(), Some(Some("someone.example".to_owned())), "clearing the default leaves the rest of the row alone" ); } #[tokio::test] async fn a_database_without_checked_at_gains_it() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.sqlite"); { let conn = Connection::open(&path).unwrap(); conn.execute_batch( "CREATE TABLE account ( did TEXT PRIMARY KEY, handle TEXT, verified_at INTEGER); INSERT INTO account (did, handle, verified_at) VALUES ('did:plc:abc', 'someone.example', 1);", ) .unwrap(); } let db = Db::open(&path).unwrap(); assert_eq!( db.account_handle("did:plc:abc").await.unwrap(), Some(Some("someone.example".to_owned())), "the existing row survives the migration" ); assert_eq!( db.default_camo("did:plc:abc").await.unwrap(), None, "a row that predates the column has no default, not an error" ); // Never looked at under the new rules, so due for a check now. assert_eq!(checked_at(&db, "did:plc:abc").await, None); assert!(db.claim_handle_check("did:plc:abc", 0).await.unwrap()); // Opening again must not try to add the column a second time. drop(db); assert!(Db::open(&path).is_ok()); } #[tokio::test] async fn oauth_session_presence() { let (_dir, db) = temp_db().await; assert!(!db.has_oauth_session("did:plc:abc").await.unwrap()); db.call(|conn| { conn.execute( "INSERT INTO oauth_session (did, value) VALUES ('did:plc:abc', '{}')", [], ) .map(|_| ()) }) .await .unwrap(); assert!(db.has_oauth_session("did:plc:abc").await.unwrap()); db.delete_oauth_session("did:plc:abc").await.unwrap(); assert!(!db.has_oauth_session("did:plc:abc").await.unwrap()); } #[tokio::test] async fn file_is_private_and_survives_reopen() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.sqlite"); let db = Db::open(&path).unwrap(); db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); drop(db); let mode = std::os::unix::fs::PermissionsExt::mode( &std::fs::metadata(&path).unwrap().permissions(), ); assert_eq!(mode & 0o777, 0o600); let db = Db::open(&path).unwrap(); assert_eq!( db.account_handle("did:plc:abc").await.unwrap(), Some(Some("someone.example".to_owned())) ); } // --- the daily challenge's one attempt -------------------------------- // // The rule the whole feature rests on: one attempt per player per // challenge, and an attempt only spent on a match that produced a result. #[tokio::test] async fn a_player_gets_one_attempt_at_a_challenge() { let (_dir, db) = temp_db().await; assert!( db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") .await .unwrap() ); assert!( !db.take_daily_attempt("parity", "did:plc:a", "m2", "2026-08-15") .await .unwrap(), "a second attempt at the same challenge was allowed" ); assert_eq!(db.daily_attempt_count("parity").await.unwrap(), 1); } #[tokio::test] async fn the_limit_is_per_challenge_and_per_player() { let (_dir, db) = temp_db().await; assert!( db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") .await .unwrap() ); // Another player, same challenge. assert!( db.take_daily_attempt("parity", "did:plc:b", "m2", "2026-08-15") .await .unwrap() ); // Same player, another challenge. assert!( db.take_daily_attempt("sinkhole", "did:plc:a", "m3", "2026-08-16") .await .unwrap() ); assert_eq!(db.daily_attempt_count("parity").await.unwrap(), 2); assert_eq!(db.daily_attempt_count("sinkhole").await.unwrap(), 1); } #[tokio::test] async fn a_match_that_produced_no_result_gives_the_attempt_back() { let (_dir, db) = temp_db().await; db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") .await .unwrap(); assert!(db.release_daily_attempt("m1").await.unwrap()); assert!( db.daily_attempt("parity", "did:plc:a") .await .unwrap() .is_none(), "the attempt was not released" ); // And the player may take it on again. assert!( db.take_daily_attempt("parity", "did:plc:a", "m2", "2026-08-15") .await .unwrap() ); } #[tokio::test] async fn a_scored_attempt_is_never_released() { // A duplicate `error` event arriving after a match scored must not // take the score away and hand back an attempt that was played. let (_dir, db) = temp_db().await; db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") .await .unwrap(); db.score_daily_attempt("m1", 750_000, (6633, 4000), (6633, 900)) .await .unwrap(); assert!(!db.release_daily_attempt("m1").await.unwrap()); let attempt = db.daily_attempt("parity", "did:plc:a").await.unwrap(); assert_eq!(attempt.map(|a| a.score), Some(Some(750_000))); } #[tokio::test] async fn an_attempt_is_bound_to_the_match_it_was_spent_on() { // The claim happens before the launch, against a placeholder, so the // result has to be able to find the attempt afterwards. let (_dir, db) = temp_db().await; db.take_daily_attempt( "parity", "did:plc:a", "pending:parity:did:plc:a", "2026-08-15", ) .await .unwrap(); db.bind_daily_attempt("parity", "did:plc:a", "match-7") .await .unwrap(); let found = db.daily_attempt_for_match("match-7").await.unwrap(); assert_eq!(found.map(|a| a.slug), Some("parity".to_owned())); } #[tokio::test] async fn releasing_a_match_that_was_never_an_attempt_does_nothing() { let (_dir, db) = temp_db().await; assert!(!db.release_daily_attempt("some-other-match").await.unwrap()); } }